@kyubiware/commit-mint 0.4.1 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.mjs CHANGED
@@ -5,12 +5,14 @@ import { intro, isCancel, log, note, outro, select, spinner } from "@clack/promp
5
5
  import { bold, cyan, dim, green, red, yellow } from "kolorist";
6
6
  import { access, constants, mkdir, readFile, writeFile } from "node:fs/promises";
7
7
  import os from "node:os";
8
- import { join } from "node:path";
8
+ import { extname, join } from "node:path";
9
9
  import ini from "ini";
10
+ import Groq from "groq-sdk";
10
11
  import { execa } from "execa";
11
12
  import { spawn } from "node:child_process";
12
- import Groq from "groq-sdk";
13
13
  import { createHash } from "node:crypto";
14
+ import { readFileSync } from "node:fs";
15
+ import picomatch from "picomatch";
14
16
  //#region \0rolldown/runtime.js
15
17
  var __defProp = Object.defineProperty;
16
18
  var __exportAll = (all, no_symbols) => {
@@ -26,8 +28,8 @@ var __exportAll = (all, no_symbols) => {
26
28
  //#region package.json
27
29
  var package_default = {
28
30
  name: "@kyubiware/commit-mint",
29
- version: "0.4.1",
30
- description: "A commit tool that actually handles hook failures",
31
+ version: "0.5.0",
32
+ description: "🌿 A commit tool that actually handles hook failures",
31
33
  type: "module",
32
34
  bin: { "cmint": "./dist/cli.mjs" },
33
35
  files: ["dist"],
@@ -45,16 +47,13 @@ var package_default = {
45
47
  "release:patch": "bash scripts/release.sh patch",
46
48
  "release:minor": "bash scripts/release.sh minor",
47
49
  "release:major": "bash scripts/release.sh major",
48
- "prepublishOnly": "npm run build",
49
- "prepare": "simple-git-hooks"
50
+ "prepublishOnly": "npm run build"
50
51
  },
51
- "simple-git-hooks": { "pre-commit": "npx lint-staged" },
52
52
  keywords: [
53
53
  "git",
54
54
  "commit",
55
55
  "hooks",
56
56
  "pre-commit",
57
- "lint-staged",
58
57
  "ai",
59
58
  "groq",
60
59
  "conventional-commits",
@@ -72,14 +71,14 @@ var package_default = {
72
71
  "execa": "^9.6.0",
73
72
  "groq-sdk": "^0.32.0",
74
73
  "ini": "^5.0.0",
75
- "kolorist": "^1.8.0"
74
+ "jiti": "^2.7.0",
75
+ "kolorist": "^1.8.0",
76
+ "picomatch": "^4.0.4"
76
77
  },
77
78
  devDependencies: {
78
79
  "@biomejs/biome": "^2.0.0",
79
80
  "@types/ini": "^4.1.1",
80
81
  "@vitest/coverage-v8": "^3.2.4",
81
- "lint-staged": "^17.0.5",
82
- "simple-git-hooks": "^2.13.1",
83
82
  "tsdown": "^0.22.0",
84
83
  "tsx": "^4.22.2",
85
84
  "typescript": "^5.9.2",
@@ -98,9 +97,52 @@ function debug(...args) {
98
97
  console.error(dim(`[debug ${timestamp}]`), ...args);
99
98
  }
100
99
  //#endregion
100
+ //#region src/services/provider.ts
101
+ const PROVIDER_CONFIGS = {
102
+ groq: {
103
+ baseURL: "https://api.groq.com",
104
+ defaultModel: "openai/gpt-oss-20b"
105
+ },
106
+ cerebras: {
107
+ baseURL: "https://api.cerebras.ai",
108
+ defaultModel: "gpt-oss-120b"
109
+ },
110
+ mistral: {
111
+ baseURL: "https://api.mistral.ai",
112
+ defaultModel: "mistral-small"
113
+ }
114
+ };
115
+ const ALLOWED_PROVIDERS = Object.keys(PROVIDER_CONFIGS);
116
+ const PROVIDER_ENV_KEYS = {
117
+ groq: "GROQ_API_KEY",
118
+ cerebras: "CEREBRAS_API_KEY",
119
+ mistral: "MISTRAL_API_KEY"
120
+ };
121
+ function formatProviderName(provider) {
122
+ return provider.charAt(0).toUpperCase() + provider.slice(1);
123
+ }
124
+ function isValidProvider(name) {
125
+ return ALLOWED_PROVIDERS.includes(name);
126
+ }
127
+ function createProvider(options) {
128
+ if (!isValidProvider(options.provider)) throw new Error(`Invalid provider "${options.provider}". Allowed values: ${ALLOWED_PROVIDERS.join(", ")}`);
129
+ const providerConfig = PROVIDER_CONFIGS[options.provider];
130
+ const model = options.modelOverride ?? providerConfig.defaultModel;
131
+ const baseURL = options.baseURLOverride ?? providerConfig.baseURL;
132
+ return {
133
+ client: new Groq({
134
+ apiKey: options.apiKey,
135
+ baseURL,
136
+ timeout: options.timeout
137
+ }),
138
+ model
139
+ };
140
+ }
141
+ //#endregion
101
142
  //#region src/services/config.ts
102
143
  const CONFIG_PATH = join(os.homedir(), ".commit-mint");
103
144
  const defaults = {
145
+ provider: "groq",
104
146
  model: "openai/gpt-oss-20b",
105
147
  locale: "en",
106
148
  "max-length": "100",
@@ -128,25 +170,250 @@ async function writeConfig(updates) {
128
170
  Object.assign(existing, updates);
129
171
  await writeFile(CONFIG_PATH, ini.stringify(existing), "utf8");
130
172
  }
131
- async function getConfigValue(key) {
132
- return (await readConfig())[key];
133
- }
134
173
  async function setConfigValue(key, value) {
135
174
  await writeConfig({ [key]: value });
136
175
  }
137
- async function getApiKey() {
138
- const envKey = process.env.GROQ_API_KEY;
139
- if (envKey) {
140
- debug("getApiKey: found in env");
141
- return envKey;
176
+ async function getProviderApiKey(provider) {
177
+ const envVar = PROVIDER_ENV_KEYS[provider];
178
+ if (envVar) {
179
+ const envValue = process.env[envVar];
180
+ if (envValue) {
181
+ debug("getProviderApiKey(%s): found in env", provider);
182
+ return envValue;
183
+ }
142
184
  }
143
185
  const config = await readConfig();
144
- if (config.GROQ_API_KEY) {
145
- debug("getApiKey: found in config");
146
- return config.GROQ_API_KEY;
186
+ const configKey = PROVIDER_ENV_KEYS[provider];
187
+ if (configKey && config[configKey]) {
188
+ debug("getProviderApiKey(%s): found in config", provider);
189
+ return config[configKey];
190
+ }
191
+ debug("getProviderApiKey(%s): not found", provider);
192
+ throw new Error(`Please set your ${formatProviderName(provider)} API key via \`cmint config set ${envVar}=<your token>\``);
193
+ }
194
+ function getModelForProvider(config, provider, defaultModel) {
195
+ return config[`model_${provider}`] ?? config.model ?? defaultModel;
196
+ }
197
+ //#endregion
198
+ //#region src/services/hooks.ts
199
+ /**
200
+ * Parse git hook error output into structured, human-readable errors.
201
+ * Handles output from lint-staged, biome, eslint, tsc, vitest, jest.
202
+ */
203
+ function parseHookErrors(stderr) {
204
+ if (!stderr) return [];
205
+ debug("parseHookErrors: stderr length=%d", stderr.length);
206
+ const errors = [];
207
+ if (stderr.includes("lint-staged") || stderr.includes("[FAILED]")) errors.push(...parseLintStagedErrors(stderr));
208
+ if (stderr.includes("biome") || stderr.includes("Biome")) errors.push(...parseBiomeErrors(stderr));
209
+ if (stderr.includes("error TS") || stderr.includes("tsc")) errors.push(...parseTscErrors(stderr));
210
+ if (stderr.includes("vitest") || stderr.includes("jest") || stderr.includes("FAIL") || stderr.includes("test failed")) errors.push(...parseTestErrors(stderr));
211
+ if (stderr.includes("eslint") || stderr.includes("ESLint")) errors.push(...parseEslintErrors(stderr));
212
+ if (errors.length === 0) {
213
+ debug("parseHookErrors: no patterns matched, using raw fallback");
214
+ errors.push({
215
+ tool: "git hooks",
216
+ message: stderr.trim(),
217
+ raw: stderr
218
+ });
219
+ }
220
+ debug("parseHookErrors: found %d errors", errors.length);
221
+ return errors;
222
+ }
223
+ function parseLintStagedErrors(output) {
224
+ const errors = [];
225
+ for (const match of output.matchAll(/\[FAILED\]\s+(.+?)\s+\[FAILED\]/g)) {
226
+ const task = match[1].trim();
227
+ errors.push({
228
+ tool: "lint-staged",
229
+ message: `Task failed: ${task}`,
230
+ raw: match[0]
231
+ });
232
+ }
233
+ return errors;
234
+ }
235
+ function parseBiomeErrors(output) {
236
+ const errors = [];
237
+ for (const match of output.matchAll(/^(.+?):(\d+):(\d+)\s+(.+)$/gm)) errors.push({
238
+ tool: "biome",
239
+ message: `${match[1]}:${match[2]}:${match[3]} — ${match[4]}`,
240
+ raw: match[0]
241
+ });
242
+ if (errors.length === 0 && output.includes("biome")) errors.push({
243
+ tool: "biome",
244
+ message: "Biome check failed. See raw output for details.",
245
+ raw: output
246
+ });
247
+ return errors;
248
+ }
249
+ function parseTscErrors(output) {
250
+ const errors = [];
251
+ for (const match of output.matchAll(/^(.+?)\((\d+),(\d+)\):\s+error\s+(TS\d+):\s+(.+)$/gm)) errors.push({
252
+ tool: "tsc",
253
+ message: `${match[1]}:${match[2]}:${match[3]} — ${match[4]}: ${match[5]}`,
254
+ raw: match[0]
255
+ });
256
+ return errors;
257
+ }
258
+ function parseTestErrors(output) {
259
+ const errors = [];
260
+ const match = /FAIL\s+(.+\.(test|spec)\..+)/.exec(output);
261
+ if (match) errors.push({
262
+ tool: output.includes("vitest") ? "vitest" : "jest",
263
+ message: `Test file failed: ${match[1]}`,
264
+ raw: output
265
+ });
266
+ if (errors.length === 0 && (output.includes("vitest") || output.includes("jest"))) errors.push({
267
+ tool: output.includes("vitest") ? "vitest" : "jest",
268
+ message: "Tests failed. See raw output for details.",
269
+ raw: output
270
+ });
271
+ return errors;
272
+ }
273
+ function parseEslintErrors(output) {
274
+ const errors = [];
275
+ const lines = output.split("\n");
276
+ let currentFile = "";
277
+ for (const line of lines) {
278
+ if (!/^\s/.test(line) && line.includes("/")) {
279
+ currentFile = line.trim();
280
+ continue;
281
+ }
282
+ const match = line.match(/^\s*(\d+):(\d+)\s+(error|warning)\s+(.+)\s{2,}(\S+)\s*$/);
283
+ if (match) {
284
+ const [, lineNum, col, severity, message, rule] = match;
285
+ const file = currentFile || "unknown";
286
+ errors.push({
287
+ tool: "eslint",
288
+ message: `${file}:${lineNum}:${col} ${severity}: ${message} (${rule})`,
289
+ raw: line.trim()
290
+ });
291
+ }
292
+ }
293
+ return errors;
294
+ }
295
+ /**
296
+ * Parse lint-staged/hook stderr output to discover which tools ran
297
+ * and whether they succeeded. Used for clean post-commit summary.
298
+ */
299
+ function parseToolChecks(stderr) {
300
+ if (!stderr) return [];
301
+ const checks = [];
302
+ for (const match of stderr.matchAll(/\[(COMPLETED|FAILED)\]\s+(.+)/g)) {
303
+ const status = match[1];
304
+ const command = match[2].trim();
305
+ if (isLintStagedMeta(command)) continue;
306
+ const tool = extractToolName(command);
307
+ if (!tool) continue;
308
+ checks.push({
309
+ tool,
310
+ ok: status === "COMPLETED"
311
+ });
312
+ }
313
+ const seen = /* @__PURE__ */ new Map();
314
+ for (const c of checks) seen.set(c.tool, c);
315
+ return [...seen.values()];
316
+ }
317
+ /** Heuristic: skip lint-staged internal metadata lines */
318
+ function isLintStagedMeta(command) {
319
+ if (/[*{}[\]]/.test(command)) return true;
320
+ if (/\s[-–—]\s(\d+\s)?files?$/.test(command)) return true;
321
+ if (/\s[-–—]\sno\s files$/.test(command)) return true;
322
+ if (/^(Running tasks|Applying modifications|Cleaning up|Backing up|Backed up|Updating Git)/.test(command)) return true;
323
+ if (/\.{3}$/.test(command)) return true;
324
+ return false;
325
+ }
326
+ /** Extract a display-friendly tool name from a lint-staged command */
327
+ function extractToolName(command) {
328
+ const unwrapped = unwrapShC(command);
329
+ if (unwrapped !== null) command = unwrapped;
330
+ command = findMeaningfulCommand(command);
331
+ return parseToolFromTokens(command.split(/\s+/));
332
+ }
333
+ /** Map common script names to their underlying tool */
334
+ const SCRIPT_MAP = {
335
+ typecheck: "tsc",
336
+ lint: "eslint",
337
+ format: "prettier"
338
+ };
339
+ /** Package managers that use [run|exec] <script|tool> pattern */
340
+ const PKG_MANAGERS = [
341
+ "npm",
342
+ "yarn",
343
+ "pnpm",
344
+ "bun"
345
+ ];
346
+ /** Parse tool name from a tokenized command */
347
+ function parseToolFromTokens(tokens) {
348
+ const first = tokens[0];
349
+ if (first === "sh" || first === "bash" || first === "zsh") return null;
350
+ if (PKG_MANAGERS.includes(first)) return parsePackageManagerTool(tokens);
351
+ if (first === "npx") return tokens[1] ?? null;
352
+ if (first === "uv") return parseUvTool(tokens);
353
+ return first;
354
+ }
355
+ /** Extract tool name from npm/yarn/pnpm/bun commands */
356
+ function parsePackageManagerTool(tokens) {
357
+ const sub = tokens[1];
358
+ if (sub === "exec") return tokens[2] ?? null;
359
+ const script = tokens[sub === "run" ? 2 : 1];
360
+ if (!script) return null;
361
+ return SCRIPT_MAP[script] ?? script;
362
+ }
363
+ /** Extract tool name from uv commands */
364
+ function parseUvTool(tokens) {
365
+ if (tokens[1] === "run") return tokens[2] ?? null;
366
+ if (tokens[1] === "tool" && tokens[2] === "run") return tokens[3] ?? null;
367
+ return null;
368
+ }
369
+ /** Unwrap sh -c 'command' or sh -c "command" wrappers */
370
+ function unwrapShC(command) {
371
+ const quoted = command.match(/^(?:sh|bash|zsh)\s+-c\s+(['"])([\s\S]*)\1$/);
372
+ if (quoted) return quoted[2];
373
+ const bare = command.match(/^(?:sh|bash|zsh)\s+-c\s+(\S+)$/);
374
+ if (bare) return bare[1];
375
+ return null;
376
+ }
377
+ /** Find the meaningful command in a && chain, skipping cd segments */
378
+ function findMeaningfulCommand(command) {
379
+ const segments = command.split(/\s*&&\s*/).map((s) => s.trim()).filter(Boolean);
380
+ for (const seg of segments) {
381
+ if (/^cd\s/.test(seg)) continue;
382
+ return seg;
147
383
  }
148
- debug("getApiKey: not found");
149
- throw new Error("Please set your Groq API key via `cmint config set GROQ_API_KEY=<your token>`");
384
+ return segments[segments.length - 1] || command;
385
+ }
386
+ //#endregion
387
+ //#region src/services/hook-progress.ts
388
+ const ansiRe = new RegExp(`${String.fromCharCode(27)}\\[[0-9;]*m`, "g");
389
+ function createStderrParser() {
390
+ let buffer = "";
391
+ return (chunk) => {
392
+ buffer += chunk;
393
+ const steps = [];
394
+ const lines = buffer.split("\n");
395
+ buffer = lines.pop() ?? "";
396
+ for (const line of lines) {
397
+ const match = line.replace(ansiRe, "").match(/\[(STARTED|COMPLETED|FAILED)\]\s+(.+)/);
398
+ if (!match) continue;
399
+ const status = match[1].toLowerCase();
400
+ const command = match[2].trim();
401
+ if (isLintStagedMeta(command)) continue;
402
+ const tool = extractToolName(command) ?? command;
403
+ steps.push({
404
+ status,
405
+ command,
406
+ tool
407
+ });
408
+ }
409
+ return steps;
410
+ };
411
+ }
412
+ function createProgressHandler(s) {
413
+ return (step) => {
414
+ if (step.status === "started") s.message(step.command);
415
+ else if (step.status === "failed") s.message(step.command);
416
+ };
150
417
  }
151
418
  //#endregion
152
419
  //#region src/services/git.ts
@@ -263,7 +530,7 @@ async function stageFiles(paths) {
263
530
  debug("stageFiles:", paths);
264
531
  await execa("git", ["add", ...paths]);
265
532
  }
266
- async function attemptCommit(message, extraArgs = []) {
533
+ async function attemptCommit(message, extraArgs = [], onProgress) {
267
534
  debug("attemptCommit:", message, extraArgs.length ? extraArgs : "(no extra args)");
268
535
  try {
269
536
  const subprocess = execa("git", [
@@ -273,8 +540,11 @@ async function attemptCommit(message, extraArgs = []) {
273
540
  ...extraArgs
274
541
  ]);
275
542
  const stderrChunks = [];
543
+ const parser = onProgress ? createStderrParser() : null;
276
544
  subprocess.stderr?.on("data", (chunk) => {
277
- stderrChunks.push(chunk.toString());
545
+ const text = chunk.toString();
546
+ stderrChunks.push(text);
547
+ if (parser && onProgress) for (const step of parser(text)) onProgress(step);
278
548
  });
279
549
  await subprocess;
280
550
  debug("attemptCommit: success");
@@ -292,411 +562,63 @@ async function attemptCommit(message, extraArgs = []) {
292
562
  };
293
563
  }
294
564
  }
295
- async function attemptCommitNoVerify(message) {
565
+ async function attemptCommitNoVerify(message, onProgress) {
296
566
  debug("attemptCommitNoVerify:", message);
297
- return attemptCommit(message, ["--no-verify"]);
567
+ return attemptCommit(message, ["--no-verify"], onProgress);
298
568
  }
299
569
  //#endregion
300
- //#region src/services/hooks.ts
301
- /**
302
- * Parse git hook error output into structured, human-readable errors.
303
- * Handles output from lint-staged, biome, eslint, tsc, vitest, jest.
304
- */
305
- function parseHookErrors(stderr) {
306
- if (!stderr) return [];
307
- debug("parseHookErrors: stderr length=%d", stderr.length);
308
- const errors = [];
309
- if (stderr.includes("lint-staged") || stderr.includes("[FAILED]")) errors.push(...parseLintStagedErrors(stderr));
310
- if (stderr.includes("biome") || stderr.includes("Biome")) errors.push(...parseBiomeErrors(stderr));
311
- if (stderr.includes("error TS") || stderr.includes("tsc")) errors.push(...parseTscErrors(stderr));
312
- if (stderr.includes("vitest") || stderr.includes("jest") || stderr.includes("FAIL") || stderr.includes("test failed")) errors.push(...parseTestErrors(stderr));
313
- if (stderr.includes("eslint") || stderr.includes("ESLint")) errors.push(...parseEslintErrors(stderr));
314
- if (errors.length === 0) {
315
- debug("parseHookErrors: no patterns matched, using raw fallback");
316
- errors.push({
317
- tool: "git hooks",
318
- message: stderr.trim(),
319
- raw: stderr
320
- });
321
- }
322
- debug("parseHookErrors: found %d errors", errors.length);
323
- return errors;
324
- }
325
- function parseLintStagedErrors(output) {
326
- const errors = [];
327
- for (const match of output.matchAll(/\[FAILED\]\s+(.+?)\s+\[FAILED\]/g)) {
328
- const task = match[1].trim();
329
- errors.push({
330
- tool: "lint-staged",
331
- message: `Task failed: ${task}`,
332
- raw: match[0]
333
- });
334
- }
335
- return errors;
570
+ //#region src/services/clipboard.ts
571
+ async function copyToClipboard(content) {
572
+ for (const [cmd, args] of [
573
+ ["wl-copy", []],
574
+ ["xclip", ["-selection", "clipboard"]],
575
+ ["xsel", ["--clipboard", "--input"]],
576
+ ["pbcopy", []]
577
+ ]) try {
578
+ if (await new Promise((resolve) => {
579
+ const child = spawn(cmd, args, { stdio: [
580
+ "pipe",
581
+ "ignore",
582
+ "ignore"
583
+ ] });
584
+ let settled = false;
585
+ const done = (result) => {
586
+ if (settled) return;
587
+ settled = true;
588
+ resolve(result);
589
+ };
590
+ child.on("error", () => done(false));
591
+ child.on("exit", (code) => {
592
+ if (code !== 0) done(false);
593
+ });
594
+ child.stdin.write(content, (err) => {
595
+ if (err) {
596
+ done(false);
597
+ return;
598
+ }
599
+ child.stdin.end(() => {
600
+ child.unref();
601
+ done(true);
602
+ });
603
+ });
604
+ })) return true;
605
+ } catch {}
606
+ return false;
336
607
  }
337
- function parseBiomeErrors(output) {
338
- const errors = [];
339
- for (const match of output.matchAll(/^(.+?):(\d+):(\d+)\s+(.+)$/gm)) errors.push({
340
- tool: "biome",
341
- message: `${match[1]}:${match[2]}:${match[3]} ${match[4]}`,
342
- raw: match[0]
343
- });
344
- if (errors.length === 0 && output.includes("biome")) errors.push({
345
- tool: "biome",
346
- message: "Biome check failed. See raw output for details.",
347
- raw: output
348
- });
349
- return errors;
608
+ //#endregion
609
+ //#region src/services/ai.ts
610
+ const MAX_DIFF_CHARS = 2e4;
611
+ function mapGroqError(error, providerLabel) {
612
+ const label = providerLabel ?? "Groq";
613
+ if (error instanceof Groq.AuthenticationError) return /* @__PURE__ */ new Error(`Invalid API key for ${label}. Run: cmint config set ${label.toUpperCase()}_API_KEY=<key>`);
614
+ if (error instanceof Groq.RateLimitError) return /* @__PURE__ */ new Error(`Rate limited by ${label}. Please wait and try again.`);
615
+ if (error instanceof Groq.APIConnectionTimeoutError) return /* @__PURE__ */ new Error("Request timed out. Check your network or try a smaller diff.");
616
+ if (error instanceof Groq.APIError) return /* @__PURE__ */ new Error(`${label} API error: ${error.message}`);
617
+ return /* @__PURE__ */ new Error(`Unexpected error: ${error instanceof Error ? error.message : String(error)}`);
350
618
  }
351
- function parseTscErrors(output) {
352
- const errors = [];
353
- for (const match of output.matchAll(/^(.+?)\((\d+),(\d+)\):\s+error\s+(TS\d+):\s+(.+)$/gm)) errors.push({
354
- tool: "tsc",
355
- message: `${match[1]}:${match[2]}:${match[3]} — ${match[4]}: ${match[5]}`,
356
- raw: match[0]
357
- });
358
- return errors;
359
- }
360
- function parseTestErrors(output) {
361
- const errors = [];
362
- const match = /FAIL\s+(.+\.(test|spec)\..+)/.exec(output);
363
- if (match) errors.push({
364
- tool: output.includes("vitest") ? "vitest" : "jest",
365
- message: `Test file failed: ${match[1]}`,
366
- raw: output
367
- });
368
- if (errors.length === 0 && (output.includes("vitest") || output.includes("jest"))) errors.push({
369
- tool: output.includes("vitest") ? "vitest" : "jest",
370
- message: "Tests failed. See raw output for details.",
371
- raw: output
372
- });
373
- return errors;
374
- }
375
- function parseEslintErrors(output) {
376
- const errors = [];
377
- for (const match of output.matchAll(/^\s*\d+:(\d+)\s+(error|warning)\s+(.+?)\s+(.+?)$/gm)) errors.push({
378
- tool: "eslint",
379
- message: `${match[2]}: ${match[3]} (${match[4]})`,
380
- raw: match[0]
381
- });
382
- return errors;
383
- }
384
- /**
385
- * Parse lint-staged/hook stderr output to discover which tools ran
386
- * and whether they succeeded. Used for clean post-commit summary.
387
- */
388
- function parseToolChecks(stderr) {
389
- if (!stderr) return [];
390
- const checks = [];
391
- for (const match of stderr.matchAll(/\[(COMPLETED|FAILED)\]\s+(.+)/g)) {
392
- const status = match[1];
393
- const command = match[2].trim();
394
- if (isLintStagedMeta(command)) continue;
395
- const tool = extractToolName(command);
396
- if (!tool) continue;
397
- checks.push({
398
- tool,
399
- ok: status === "COMPLETED"
400
- });
401
- }
402
- const seen = /* @__PURE__ */ new Map();
403
- for (const c of checks) seen.set(c.tool, c);
404
- return [...seen.values()];
405
- }
406
- /** Heuristic: skip lint-staged internal metadata lines */
407
- function isLintStagedMeta(command) {
408
- if (/[*{}[\]]/.test(command)) return true;
409
- if (/\s[-–—]\s(\d+\s)?files?$/.test(command)) return true;
410
- if (/\s[-–—]\sno\s files$/.test(command)) return true;
411
- if (/^(Running tasks|Applying modifications|Cleaning up|Backing up|Backed up|Updating Git)/.test(command)) return true;
412
- if (/\.{3}$/.test(command)) return true;
413
- return false;
414
- }
415
- /** Extract a display-friendly tool name from a lint-staged command */
416
- function extractToolName(command) {
417
- const tokens = command.split(/\s+/);
418
- const first = tokens[0];
419
- if ([
420
- "npm",
421
- "yarn",
422
- "pnpm",
423
- "bun"
424
- ].includes(first)) {
425
- const script = tokens[tokens[1] === "run" ? 2 : 1];
426
- if (!script) return null;
427
- return {
428
- typecheck: "tsc",
429
- lint: "eslint",
430
- format: "prettier"
431
- }[script] ?? script;
432
- }
433
- if (first === "npx") return tokens[1] ?? null;
434
- return first;
435
- }
436
- //#endregion
437
- //#region src/services/lint-staged.ts
438
- const CONFIG_FILES = [
439
- ".lintstagedrc",
440
- ".lintstagedrc.json",
441
- ".lintstagedrc.yaml",
442
- ".lintstagedrc.yml",
443
- ".lintstagedrc.mjs",
444
- ".lintstagedrc.cjs",
445
- "lint-staged.config.mjs",
446
- "lint-staged.config.cjs",
447
- "lint-staged.config.js"
448
- ];
449
- async function hasLintStagedConfig(repoRoot) {
450
- debug("hasLintStagedConfig: checking in %s", repoRoot);
451
- for (const file of CONFIG_FILES) {
452
- const path = join(repoRoot, file);
453
- try {
454
- await access(path, constants.F_OK);
455
- debug("hasLintStagedConfig: found %s", file);
456
- return true;
457
- } catch {}
458
- }
459
- const packageJsonPath = join(repoRoot, "package.json");
460
- try {
461
- const raw = await readFile(packageJsonPath, "utf8");
462
- if ("lint-staged" in JSON.parse(raw)) {
463
- debug("hasLintStagedConfig: found lint-staged in package.json");
464
- return true;
465
- }
466
- } catch {}
467
- debug("hasLintStagedConfig: no config found");
468
- return false;
469
- }
470
- async function runLintStaged() {
471
- debug("runLintStaged: starting npx lint-staged");
472
- const { failed, stdout, stderr } = await execa("npx", ["lint-staged"], { reject: false });
473
- debug("runLintStaged: finished, failed=%s", failed);
474
- return {
475
- ok: !failed,
476
- stdout,
477
- stderr
478
- };
479
- }
480
- //#endregion
481
- //#region src/services/clipboard.ts
482
- async function copyToClipboard(content) {
483
- for (const [cmd, args] of [
484
- ["wl-copy", []],
485
- ["xclip", ["-selection", "clipboard"]],
486
- ["xsel", ["--clipboard", "--input"]],
487
- ["pbcopy", []]
488
- ]) try {
489
- if (await new Promise((resolve) => {
490
- const child = spawn(cmd, args, { stdio: [
491
- "pipe",
492
- "ignore",
493
- "ignore"
494
- ] });
495
- let settled = false;
496
- const done = (result) => {
497
- if (settled) return;
498
- settled = true;
499
- resolve(result);
500
- };
501
- child.on("error", () => done(false));
502
- child.on("exit", (code) => {
503
- if (code !== 0) done(false);
504
- });
505
- child.stdin.write(content, (err) => {
506
- if (err) {
507
- done(false);
508
- return;
509
- }
510
- child.stdin.end(() => {
511
- child.unref();
512
- done(true);
513
- });
514
- });
515
- })) return true;
516
- } catch {}
517
- return false;
518
- }
519
- //#endregion
520
- //#region src/ui/menu.ts
521
- async function showStagingMenu(files, hasLintStaged) {
522
- debug("showStagingMenu: %d files", files.length);
523
- const statusLabel = (status) => {
524
- switch (status) {
525
- case "M": return yellow("M");
526
- case "A": return green("A");
527
- case "D": return red("D");
528
- case "?":
529
- case "??": return cyan("?");
530
- default: return dim(status);
531
- }
532
- };
533
- const sorted = [...files].sort((a, b) => {
534
- if (a.staged !== b.staged) return a.staged ? -1 : 1;
535
- return a.path.localeCompare(b.path);
536
- });
537
- const stagedFiles = sorted.filter((f) => f.staged);
538
- const unstagedFiles = sorted.filter((f) => !f.staged);
539
- const lines = [];
540
- if (stagedFiles.length > 0) lines.push(green(bold("Staged:")), ...stagedFiles.map((f) => ` ${statusLabel(f.status)} ${f.path}`));
541
- if (unstagedFiles.length > 0) {
542
- if (lines.length > 0) lines.push("");
543
- lines.push(yellow(bold("Changed:")), ...unstagedFiles.map((f) => ` ${statusLabel(f.status)} ${f.path}`));
544
- }
545
- p.note(lines.join("\n"), `${files.length} file${files.length !== 1 ? "s" : ""}`);
546
- const choice = await p.select({
547
- message: "Stage files for commit:",
548
- options: [
549
- {
550
- label: "Auto-group into commits",
551
- value: "autogroup",
552
- hint: "LLM groups files into logical commits"
553
- },
554
- {
555
- label: "Stage all files",
556
- value: "all",
557
- hint: `${files.length} file${files.length !== 1 ? "s" : ""}`
558
- },
559
- ...hasLintStaged ? [{
560
- label: "Run lint-staged checks",
561
- value: "lint-staged",
562
- hint: "Pre-flight checks on all changed files"
563
- }] : [],
564
- {
565
- label: "Select files...",
566
- value: "select"
567
- },
568
- {
569
- label: "Cancel",
570
- value: "cancel"
571
- }
572
- ]
573
- });
574
- if (p.isCancel(choice) || choice === "cancel") return null;
575
- if (choice === "autogroup") return "autogroup";
576
- if (choice === "lint-staged") return "lint-staged";
577
- if (choice === "all") return {
578
- files: files.map((f) => f.path),
579
- all: true
580
- };
581
- const selected = await p.multiselect({
582
- message: "Select files to stage:",
583
- options: sorted.map((f) => ({
584
- label: `${statusLabel(f.status)} ${f.path}`,
585
- value: f.path
586
- })),
587
- required: true
588
- });
589
- if (p.isCancel(selected)) return null;
590
- return {
591
- files: selected,
592
- all: false
593
- };
594
- }
595
- async function showRecoveryMenu(errors, onRetry, onSkipHooks, onRestage, message, rawStderr) {
596
- debug("showRecoveryMenu: %d errors", errors.length);
597
- let clipboardCopied = false;
598
- let showNote = true;
599
- while (true) {
600
- if (showNote) {
601
- p.note(errors.map((e) => ` ${red("•")} [${e.tool}] ${e.message}`).join("\n"), red(bold("Pre-commit hook failed")));
602
- showNote = false;
603
- }
604
- const choice = await p.select({
605
- message: "What do you want to do?",
606
- options: [
607
- {
608
- label: clipboardCopied ? `${green("✓")} Copy error report to clipboard` : "Copy error report to clipboard",
609
- value: "clipboard",
610
- hint: clipboardCopied ? "Copied!" : "Paste into another terminal for an AI agent"
611
- },
612
- {
613
- label: "Skip hooks and commit (--no-verify)",
614
- value: "skip",
615
- hint: "Commit anyway, fix later"
616
- },
617
- {
618
- label: "Re-stage files and retry",
619
- value: "restage",
620
- hint: "Pick up fixes from another terminal"
621
- },
622
- {
623
- label: "Edit commit message",
624
- value: "edit",
625
- hint: "Modify the message before retrying"
626
- },
627
- {
628
- label: "Cancel",
629
- value: "cancel"
630
- }
631
- ]
632
- });
633
- if (p.isCancel(choice)) {
634
- debug("showRecoveryMenu: user cancelled");
635
- p.outro(yellow("Cancelled. Message cached for --retry."));
636
- return "cancelled";
637
- }
638
- debug("showRecoveryMenu: user chose %s", choice);
639
- switch (choice) {
640
- case "clipboard":
641
- if (await copyToClipboard(rawStderr)) {
642
- clipboardCopied = true;
643
- p.log.step(green("Copied to clipboard."));
644
- } else p.log.warn(red("No clipboard tool found. Install xclip, wl-copy, or xsel."));
645
- continue;
646
- case "skip":
647
- p.log.info(yellow("Committing with --no-verify..."));
648
- if (await onSkipHooks(message)) {
649
- p.outro(green("Committed (hooks skipped)."));
650
- return "committed";
651
- } else {
652
- p.outro(red("Commit failed even with --no-verify."));
653
- return "failed";
654
- }
655
- case "restage":
656
- p.log.info(cyan("Re-staging and retrying..."));
657
- if (await onRestage()) {
658
- p.outro(green("Committed successfully."));
659
- return "committed";
660
- }
661
- showNote = true;
662
- continue;
663
- case "edit": {
664
- const edited = await p.text({
665
- message: "Edit commit message:",
666
- initialValue: message,
667
- validate: (v) => v?.trim() ? void 0 : "Message cannot be empty"
668
- });
669
- if (p.isCancel(edited)) {
670
- p.outro(yellow("Cancelled. Message cached for --retry."));
671
- return "cancelled";
672
- }
673
- if (await onRetry()) {
674
- p.outro(green("Committed successfully."));
675
- return "committed";
676
- } else {
677
- p.outro(red("Commit failed again."));
678
- return "failed";
679
- }
680
- }
681
- case "cancel":
682
- p.outro(dim("Message cached for --retry."));
683
- return "cancelled";
684
- }
685
- }
686
- }
687
- //#endregion
688
- //#region src/services/ai.ts
689
- const MAX_DIFF_CHARS = 2e4;
690
- function mapGroqError$1(error) {
691
- if (error instanceof Groq.AuthenticationError) return /* @__PURE__ */ new Error("Invalid GROQ_API_KEY. Run: cmint config set GROQ_API_KEY=<key>");
692
- if (error instanceof Groq.RateLimitError) return /* @__PURE__ */ new Error("Rate limited by Groq. Please wait and try again.");
693
- if (error instanceof Groq.APIConnectionTimeoutError) return /* @__PURE__ */ new Error("Request timed out. Check your network or try a smaller diff.");
694
- if (error instanceof Groq.APIError) return /* @__PURE__ */ new Error(`Groq API error: ${error.message}`);
695
- return /* @__PURE__ */ new Error(`Unexpected error: ${error instanceof Error ? error.message : String(error)}`);
696
- }
697
- const CONVENTIONAL_COMMIT_REGEX = /^(build|chore|ci|docs|feat|fix|perf|refactor|revert|style|test)(\(.+\))?!?: .+$/;
698
- function stripThinkTags(text) {
699
- return text.replace(/<think[\s\S]*?<\/think>/gi, "").trim();
619
+ const CONVENTIONAL_COMMIT_REGEX = /^(build|chore|ci|docs|feat|fix|perf|refactor|revert|style|test)(\(.+\))?!?: .+$/;
620
+ function stripThinkTags(text) {
621
+ return text.replace(/<think[\s\S]*?<\/think>/gi, "").trim();
700
622
  }
701
623
  function deriveMessageFromReasoning(reasoning) {
702
624
  const match = reasoning.match(/(build|chore|ci|docs|feat|fix|perf|refactor|revert|style|test)(\(.+\))?!?: .+/i);
@@ -776,13 +698,16 @@ function extractContentText(content) {
776
698
  return "";
777
699
  }
778
700
  async function generateCommitMessage(diff, options) {
779
- debug("generateCommitMessage: model=%s, type=%s, hint=%s", options.model ?? "default", options.type ?? "none", options.hint ?? "none");
780
701
  const timeoutMs = options.timeout ?? 6e4;
781
702
  debug("Timeout: %d ms", timeoutMs);
782
- const client = new Groq({
703
+ const { client, model } = createProvider({
704
+ provider: options.provider ?? "groq",
783
705
  apiKey: options.apiKey,
784
- timeout: timeoutMs
706
+ modelOverride: options.model,
707
+ timeout: timeoutMs,
708
+ baseURLOverride: options.proxy
785
709
  });
710
+ debug("generateCommitMessage: model=%s, type=%s, hint=%s", model, options.type ?? "none", options.hint ?? "none");
786
711
  const compressedDiff = compressDiff(diff);
787
712
  const statSummary = buildStatSummary(diff);
788
713
  const systemPrompt = buildSystemPrompt(options.type);
@@ -792,9 +717,9 @@ async function generateCommitMessage(diff, options) {
792
717
  debug("User prompt length: %d chars", userPrompt.length);
793
718
  async function callAI(strictSystemPrompt) {
794
719
  const callStart = Date.now();
795
- debug("callAI: %s — model=%s, promptLen=%d, systemLen=%d", !!strictSystemPrompt ? "RETRY (strict)" : "INITIAL", options.model ?? "openai/gpt-oss-20b", userPrompt.length, (strictSystemPrompt ?? systemPrompt).length);
720
+ debug("callAI: %s — model=%s, promptLen=%d, systemLen=%d", !!strictSystemPrompt ? "RETRY (strict)" : "INITIAL", model, userPrompt.length, (strictSystemPrompt ?? systemPrompt).length);
796
721
  try {
797
- const isReasoningModel = /^(o[1-9]|.*gpt-oss.*|.*gpt-5.*)/i.test(options.model ?? "");
722
+ const isReasoningModel = /^(o[1-9]|.*gpt-oss.*|.*gpt-5.*)/i.test(model);
798
723
  const completion = await client.chat.completions.create({
799
724
  messages: [{
800
725
  role: "system",
@@ -803,7 +728,7 @@ async function generateCommitMessage(diff, options) {
803
728
  role: "user",
804
729
  content: userPrompt
805
730
  }],
806
- model: options.model ?? "openai/gpt-oss-20b",
731
+ model,
807
732
  temperature: .3,
808
733
  ...isReasoningModel ? { max_completion_tokens: 1024 } : { max_tokens: 1024 },
809
734
  reasoning_format: "parsed"
@@ -849,7 +774,7 @@ async function generateCommitMessage(diff, options) {
849
774
  return message;
850
775
  } catch (error) {
851
776
  debug("AI error: %s", error instanceof Error ? error.message : String(error));
852
- throw mapGroqError$1(error);
777
+ throw mapGroqError(error, options.provider ? formatProviderName(options.provider) : void 0);
853
778
  }
854
779
  }
855
780
  //#endregion
@@ -892,9 +817,12 @@ function buildReviewPrompt(diff, files, statSummary) {
892
817
  async function generateCodeReview(diff, files, options) {
893
818
  debug("generateCodeReview: model=%s, files=%d", options.model ?? "default", files.length);
894
819
  const timeoutMs = options.timeout ?? 6e4;
895
- const client = new Groq({
820
+ const { client, model } = createProvider({
821
+ provider: options.provider ?? "groq",
896
822
  apiKey: options.apiKey,
897
- timeout: timeoutMs
823
+ modelOverride: options.model,
824
+ timeout: timeoutMs,
825
+ baseURLOverride: options.proxy
898
826
  });
899
827
  const compressedDiff = compressDiff(diff);
900
828
  const statSummary = buildStatSummary(diff);
@@ -910,7 +838,7 @@ async function generateCodeReview(diff, files, options) {
910
838
  role: "user",
911
839
  content: userPrompt
912
840
  }],
913
- model: options.model ?? "openai/gpt-oss-20b",
841
+ model,
914
842
  temperature: .3,
915
843
  max_tokens: 4096
916
844
  });
@@ -931,7 +859,7 @@ async function generateCodeReview(diff, files, options) {
931
859
  return content;
932
860
  } catch (error) {
933
861
  debug("generateCodeReview error: %s", error instanceof Error ? error.message : String(error));
934
- throw mapGroqError$1(error);
862
+ throw mapGroqError(error, options.provider ? formatProviderName(options.provider) : void 0);
935
863
  }
936
864
  }
937
865
  //#endregion
@@ -952,7 +880,7 @@ async function reviewCommand() {
952
880
  outro(dim("Staged files are all excluded from review."));
953
881
  return;
954
882
  }
955
- intro("commit-mint — code review");
883
+ intro("🌿 commit-mint — code review");
956
884
  log.info(diffResult.files.map((f) => ` ${f}`).join("\n"));
957
885
  const report = await isOpenCodeAvailable() ? await reviewWithOpenCode(diffResult.diff, diffResult.files) : await reviewWithGroq(diffResult.diff, diffResult.files);
958
886
  if (report !== "NO_ISSUES_FOUND" && report.trim().length > 0) {
@@ -987,14 +915,19 @@ async function isOpenCodeAvailable() {
987
915
  }
988
916
  }
989
917
  async function reviewWithGroq(diff, files) {
918
+ const config = await readConfig();
919
+ const provider = isValidProvider(config.provider ?? "groq") ? config.provider : "groq";
920
+ const apiKey = await getProviderApiKey(provider);
921
+ const model = getModelForProvider(config, provider, PROVIDER_CONFIGS[provider].defaultModel);
990
922
  const s = spinner();
991
- s.start("Reviewing with Groq...");
923
+ s.start(`Reviewing with ${formatProviderName(provider)}...`);
992
924
  try {
993
- const config = await readConfig();
994
925
  const report = await generateCodeReview(diff, files, {
995
- apiKey: await getApiKey(),
996
- model: config.model,
997
- timeout: config.timeout ? Number.parseInt(config.timeout, 10) : void 0
926
+ apiKey,
927
+ model,
928
+ timeout: config.timeout ? Number.parseInt(config.timeout, 10) : void 0,
929
+ provider,
930
+ proxy: config.proxy
998
931
  });
999
932
  s.stop("Review complete");
1000
933
  return report;
@@ -1050,8 +983,10 @@ async function runCodeReview() {
1050
983
  return;
1051
984
  }
1052
985
  const opencodeAvailable = await isOpenCodeAvailable();
986
+ const config = await readConfig();
987
+ const provider = isValidProvider(config.provider ?? "groq") ? config.provider : "groq";
1053
988
  const s = spinner();
1054
- s.start(opencodeAvailable ? "Running OpenCode review..." : "Running Groq review...");
989
+ s.start(opencodeAvailable ? "Running OpenCode review..." : `Running ${formatProviderName(provider)} review...`);
1055
990
  try {
1056
991
  const report = opencodeAvailable ? await reviewWithOpenCode(diffResult.diff, diffResult.files) : await reviewWithGroq(diffResult.diff, diffResult.files);
1057
992
  s.stop("Review complete");
@@ -1167,14 +1102,201 @@ async function loadCachedCommit(repoPath) {
1167
1102
  }
1168
1103
  }
1169
1104
  //#endregion
1170
- //#region src/services/grouping.ts
1171
- function mapGroqError(error) {
1172
- if (error instanceof Groq.AuthenticationError) return /* @__PURE__ */ new Error("Invalid GROQ_API_KEY. Run: cmint config set GROQ_API_KEY=<key>");
1173
- if (error instanceof Groq.RateLimitError) return /* @__PURE__ */ new Error("Rate limited by Groq. Please wait and try again.");
1174
- if (error instanceof Groq.APIConnectionTimeoutError) return /* @__PURE__ */ new Error("Request timed out. Check your network and try again.");
1175
- if (error instanceof Groq.APIError) return /* @__PURE__ */ new Error(`Groq API error: ${error.message}`);
1176
- return /* @__PURE__ */ new Error(`Unexpected error: ${error instanceof Error ? error.message : String(error)}`);
1105
+ //#region src/services/checks.ts
1106
+ /** Config file names, checked in priority order (matches lint-staged naming conventions) */
1107
+ const CONFIG_FILES = [
1108
+ ".cmintrc",
1109
+ ".cmintrc.json",
1110
+ ".cmintrc.mjs",
1111
+ ".cmintrc.mts",
1112
+ ".cmintrc.js",
1113
+ ".cmintrc.ts",
1114
+ ".cmintrc.cjs",
1115
+ ".cmintrc.cts",
1116
+ "cmint.config.mjs",
1117
+ "cmint.config.mts",
1118
+ "cmint.config.js",
1119
+ "cmint.config.ts",
1120
+ "cmint.config.cjs",
1121
+ "cmint.config.cts"
1122
+ ];
1123
+ /**
1124
+ * Detect whether the repo has a cmint config file.
1125
+ * Returns the config file path, or null if none found.
1126
+ */
1127
+ async function detectConfig(repoRoot) {
1128
+ debug("detectConfig: checking for config in %s", repoRoot);
1129
+ for (const name of CONFIG_FILES) try {
1130
+ await access(join(repoRoot, name), constants.R_OK);
1131
+ debug("detectConfig: found %s", name);
1132
+ return join(repoRoot, name);
1133
+ } catch {}
1134
+ debug("detectConfig: no config file found");
1135
+ return null;
1136
+ }
1137
+ /**
1138
+ * Load and validate the cmint config from a repo root.
1139
+ * Throws if the loaded value is missing or not a non-null object.
1140
+ */
1141
+ async function loadConfig(repoRoot) {
1142
+ const configPath = await detectConfig(repoRoot);
1143
+ if (!configPath) throw new Error("No cmint config file found");
1144
+ debug("loadConfig: loading %s", configPath);
1145
+ const ext = extname(configPath);
1146
+ const isJSON = ext === ".json";
1147
+ const needsJiti = ext === ".ts" || ext === ".mts" || ext === ".cts" || ext === ".cjs";
1148
+ let config;
1149
+ if (isJSON) {
1150
+ const raw = readFileSync(configPath, "utf-8");
1151
+ config = JSON.parse(raw);
1152
+ } else if (needsJiti) {
1153
+ const { createJiti } = await import("jiti");
1154
+ const mod = await createJiti(import.meta.url, {}).import(configPath);
1155
+ config = mod.default ?? mod;
1156
+ } else config = (await import(configPath)).default;
1157
+ if (!config || typeof config !== "object" || Array.isArray(config)) throw new Error("cmint config must export a non-null object with glob→command mappings");
1158
+ debug("loadConfig: loaded %d glob patterns", Object.keys(config).length);
1159
+ return config;
1160
+ }
1161
+ /**
1162
+ * Run a shell command and capture its output.
1163
+ * Returns a CheckResult with ok=true on success (exit 0), ok=false on failure.
1164
+ * Handles ENOENT (command not found) and timeout errors gracefully.
1165
+ */
1166
+ async function runCommand(command, timeout, repoRoot) {
1167
+ debug("runCommand: %s (timeout: %dms)", command, timeout);
1168
+ const tool = extractToolName(command) ?? command.split(" ")[0];
1169
+ try {
1170
+ const result = await execa(command, {
1171
+ shell: true,
1172
+ reject: false,
1173
+ timeout,
1174
+ all: true,
1175
+ preferLocal: true,
1176
+ ...repoRoot ? { localDir: repoRoot } : {}
1177
+ });
1178
+ const ok = !result.failed;
1179
+ debug("runCommand: %s — ok=%s", tool, ok);
1180
+ return {
1181
+ ok,
1182
+ tool,
1183
+ command,
1184
+ stdout: result.stdout ?? "",
1185
+ stderr: result.stderr ?? "",
1186
+ files: []
1187
+ };
1188
+ } catch (err) {
1189
+ const msg = err instanceof Error ? err.message : String(err);
1190
+ const isTimedOut = msg.toLowerCase().includes("timed out");
1191
+ const isNotFound = msg.toLowerCase().includes("enoent") || msg.toLowerCase().includes("not found");
1192
+ debug("runCommand: %s — error: %s", tool, msg);
1193
+ return {
1194
+ ok: false,
1195
+ tool,
1196
+ command,
1197
+ stdout: "",
1198
+ stderr: isTimedOut ? `Check timed out after ${timeout}ms` : isNotFound ? `Command not found: ${tool}` : msg,
1199
+ files: []
1200
+ };
1201
+ }
1202
+ }
1203
+ /**
1204
+ * Filter a list of file paths by a picomatch glob pattern.
1205
+ * When the pattern contains no `/`, files are matched at any depth (matchBase).
1206
+ * Dotfiles are included (dot: true).
1207
+ */
1208
+ function matchFiles(pattern, files) {
1209
+ if (!pattern) return [];
1210
+ const matchBase = !pattern.includes("/");
1211
+ const isMatch = picomatch(pattern, {
1212
+ dot: true,
1213
+ posixSlashes: true,
1214
+ strictBrackets: true
1215
+ });
1216
+ return files.filter((f) => {
1217
+ const parts = f.split("/");
1218
+ return isMatch(matchBase ? parts[parts.length - 1] : f);
1219
+ });
1220
+ }
1221
+ /**
1222
+ * Build a shell command string from a base command and a list of file paths.
1223
+ * File paths containing spaces are wrapped in double quotes.
1224
+ * If no files are provided, the base command is returned as-is.
1225
+ */
1226
+ function buildCommand(command, files) {
1227
+ if (files.length === 0) return command;
1228
+ return `${command} ${files.map((f) => f.includes(" ") ? `"${f}"` : f).join(" ")}`;
1229
+ }
1230
+ /**
1231
+ * Resolve config commands for a glob entry into an array of command strings.
1232
+ * Function commands receive matched filenames; string commands are used as-is.
1233
+ */
1234
+ function resolveCommands(commands, matchedFiles) {
1235
+ if (typeof commands === "function") {
1236
+ const resolved = commands(matchedFiles);
1237
+ return Array.isArray(resolved) ? resolved : [resolved];
1238
+ }
1239
+ return Array.isArray(commands) ? commands : [commands];
1240
+ }
1241
+ /**
1242
+ * Run resolved commands for a single glob entry, appending results.
1243
+ * Returns false if any command fails (for fail-fast signaling).
1244
+ */
1245
+ async function runCommandsForGlob(cmds, isFunction, matchedFiles, timeout, results, repoRoot) {
1246
+ for (const cmd of cmds) {
1247
+ const fullCommand = isFunction ? cmd : buildCommand(cmd, matchedFiles);
1248
+ debug("runCommandsForGlob: running '%s'", fullCommand);
1249
+ const result = await runCommand(fullCommand, timeout, repoRoot);
1250
+ results.push({
1251
+ ...result,
1252
+ files: matchedFiles
1253
+ });
1254
+ if (!result.ok) {
1255
+ debug("runCommandsForGlob: check failed, stopping (fail-fast)");
1256
+ return false;
1257
+ }
1258
+ }
1259
+ return true;
1260
+ }
1261
+ /**
1262
+ * Run all user-defined checks from .cmintrc against staged files.
1263
+ * Returns a no-op result when no config exists.
1264
+ * Fail-fast: stops on first error.
1265
+ */
1266
+ async function runAllChecks(repoRoot, stagedFiles, timeout) {
1267
+ debug("runAllChecks: %d staged files, checking for config in %s", stagedFiles.length, repoRoot);
1268
+ if (!await detectConfig(repoRoot)) {
1269
+ debug("runAllChecks: no config found, skipping checks");
1270
+ return {
1271
+ ok: true,
1272
+ results: []
1273
+ };
1274
+ }
1275
+ const config = await loadConfig(repoRoot);
1276
+ debug("runAllChecks: loaded config with %d patterns", Object.keys(config).length);
1277
+ const results = [];
1278
+ for (const [glob, commands] of Object.entries(config)) {
1279
+ const matchedFiles = matchFiles(glob, stagedFiles);
1280
+ const isFunction = typeof commands === "function";
1281
+ if (matchedFiles.length === 0) {
1282
+ debug("runAllChecks: no files matched pattern '%s'", glob);
1283
+ continue;
1284
+ }
1285
+ debug("runAllChecks: pattern '%s' matched %d files", glob, matchedFiles.length);
1286
+ if (!await runCommandsForGlob(resolveCommands(commands, matchedFiles), isFunction, matchedFiles, timeout, results, repoRoot)) return {
1287
+ ok: false,
1288
+ results
1289
+ };
1290
+ }
1291
+ const ok = results.every((r) => r.ok);
1292
+ debug("runAllChecks: complete — ok=%s, %d results", ok, results.length);
1293
+ return {
1294
+ ok,
1295
+ results
1296
+ };
1177
1297
  }
1298
+ //#endregion
1299
+ //#region src/services/grouping.ts
1178
1300
  function matchesExcludePattern(filePath, pattern) {
1179
1301
  if (pattern === filePath) return true;
1180
1302
  if (pattern.endsWith("/**")) {
@@ -1191,7 +1313,9 @@ function matchesExcludePattern(filePath, pattern) {
1191
1313
  const LOCKFILE_COMPANIONS = {
1192
1314
  "package-lock.json": "package.json",
1193
1315
  "pnpm-lock.yaml": "package.json",
1194
- "yarn.lock": "package.json"
1316
+ "yarn.lock": "package.json",
1317
+ "bun.lock": "package.json",
1318
+ "bun.lockb": "package.json"
1195
1319
  };
1196
1320
  function filterExcludedFiles(files) {
1197
1321
  const patterns = getDefaultExcludes();
@@ -1265,7 +1389,7 @@ function parseGroupingResponse(content) {
1265
1389
  });
1266
1390
  return rawGroups;
1267
1391
  }
1268
- async function generateGroups(files, apiKey, model, timeout) {
1392
+ async function generateGroups(files, apiKey, model, timeout, provider, proxy) {
1269
1393
  debug("generateGroups: %d files, model=%s", files.length, model ?? "default");
1270
1394
  const { included, excluded } = filterExcludedFiles(files);
1271
1395
  if (included.length === 0) {
@@ -1280,9 +1404,12 @@ async function generateGroups(files, apiKey, model, timeout) {
1280
1404
  const userPrompt = buildGroupingUserPrompt(summary);
1281
1405
  debug("File summary:\n%s", summary);
1282
1406
  debug("User prompt length: %d chars", userPrompt.length);
1283
- const client = new Groq({
1407
+ const { client, model: resolvedModel } = createProvider({
1408
+ provider: provider ?? "groq",
1284
1409
  apiKey,
1285
- timeout: timeout ?? 6e4
1410
+ modelOverride: model,
1411
+ timeout: timeout ?? 6e4,
1412
+ baseURLOverride: proxy
1286
1413
  });
1287
1414
  try {
1288
1415
  const completion = await client.chat.completions.create({
@@ -1293,7 +1420,7 @@ async function generateGroups(files, apiKey, model, timeout) {
1293
1420
  role: "user",
1294
1421
  content: userPrompt
1295
1422
  }],
1296
- model: model ?? "openai/gpt-oss-20b",
1423
+ model: resolvedModel,
1297
1424
  temperature: .3,
1298
1425
  max_tokens: 2048
1299
1426
  });
@@ -1312,7 +1439,7 @@ async function generateGroups(files, apiKey, model, timeout) {
1312
1439
  };
1313
1440
  } catch (error) {
1314
1441
  debug("generateGroups error: %s", error instanceof Error ? error.message : String(error));
1315
- throw mapGroqError(error);
1442
+ throw mapGroqError(error, provider ? formatProviderName(provider) : void 0);
1316
1443
  }
1317
1444
  }
1318
1445
  function validateGroups(groups, allFiles) {
@@ -1339,72 +1466,367 @@ function validateGroups(groups, allFiles) {
1339
1466
  files: ungrouped.map((f) => f.path)
1340
1467
  });
1341
1468
  }
1342
- return validated;
1469
+ return validated;
1470
+ }
1471
+ //#endregion
1472
+ //#region src/ui/grouping.ts
1473
+ async function showGroupingConfirmation(groups, excluded) {
1474
+ debug("showGroupingConfirmation: %d groups, %d excluded", groups.length, excluded.length);
1475
+ const lines = [];
1476
+ for (const group of groups) {
1477
+ lines.push(bold(group.name));
1478
+ lines.push(` ${dim(group.description)}`);
1479
+ lines.push(` ${green(String(group.files.length))} file${group.files.length !== 1 ? "s" : ""}`);
1480
+ for (const file of group.files) lines.push(` ${dim("•")} ${file}`);
1481
+ lines.push("");
1482
+ }
1483
+ if (excluded.length > 0) {
1484
+ lines.push(dim(`Excluded: ${excluded.length} file${excluded.length !== 1 ? "s" : ""}`));
1485
+ for (const file of excluded) lines.push(` ${dim("•")} ${dim(file)}`);
1486
+ }
1487
+ p.note(lines.join("\n"), "Proposed commit groups");
1488
+ const choice = await p.select({
1489
+ message: "Proceed with these groupings?",
1490
+ options: [{
1491
+ label: "Yes, commit all groups",
1492
+ value: "yes"
1493
+ }, {
1494
+ label: "No, cancel",
1495
+ value: "no"
1496
+ }]
1497
+ });
1498
+ if (p.isCancel(choice) || choice === "no") {
1499
+ debug("showGroupingConfirmation: user cancelled");
1500
+ return false;
1501
+ }
1502
+ debug("showGroupingConfirmation: user confirmed");
1503
+ return true;
1504
+ }
1505
+ function showGroupProgress(current, total, groupName) {
1506
+ p.log.info(`Commit group ${current} of ${total}: ${cyan(`"${groupName}"`)}`);
1507
+ }
1508
+ const statusLabel = (status) => {
1509
+ switch (status) {
1510
+ case "M": return yellow("M");
1511
+ case "A": return green("A");
1512
+ case "D": return red("D");
1513
+ case "?":
1514
+ case "??": return cyan("?");
1515
+ default: return dim(status);
1516
+ }
1517
+ };
1518
+ /** Display a table of changed files with status indicators */
1519
+ function showChangedFilesTable(files) {
1520
+ if (files.length === 0) return;
1521
+ const lines = files.map((f) => ` ${statusLabel(f.status)} ${f.path}`);
1522
+ p.note(lines.join("\n"), `${files.length} file${files.length !== 1 ? "s" : ""} changed`);
1523
+ }
1524
+ /** Display a compact grouping summary (only shown when >1 group) */
1525
+ function showGroupingSummary(groups) {
1526
+ if (groups.length <= 1) return;
1527
+ const lines = groups.map((g) => `${bold(g.name)} ${dim("—")} ${g.files.length} file${g.files.length !== 1 ? "s" : ""}`);
1528
+ p.note(lines.join("\n"), "Commit groups");
1529
+ }
1530
+ //#endregion
1531
+ //#region src/ui/menu.ts
1532
+ async function showStagingMenu(files, hasChecks) {
1533
+ debug("showStagingMenu: %d files", files.length);
1534
+ const statusLabel = (status) => {
1535
+ switch (status) {
1536
+ case "M": return yellow("M");
1537
+ case "A": return green("A");
1538
+ case "D": return red("D");
1539
+ case "?":
1540
+ case "??": return cyan("?");
1541
+ default: return dim(status);
1542
+ }
1543
+ };
1544
+ const sorted = [...files].sort((a, b) => {
1545
+ if (a.staged !== b.staged) return a.staged ? -1 : 1;
1546
+ return a.path.localeCompare(b.path);
1547
+ });
1548
+ const stagedFiles = sorted.filter((f) => f.staged);
1549
+ const unstagedFiles = sorted.filter((f) => !f.staged);
1550
+ const lines = [];
1551
+ if (stagedFiles.length > 0) lines.push(green(bold("Staged:")), ...stagedFiles.map((f) => ` ${statusLabel(f.status)} ${f.path}`));
1552
+ if (unstagedFiles.length > 0) {
1553
+ if (lines.length > 0) lines.push("");
1554
+ lines.push(yellow(bold("Changed:")), ...unstagedFiles.map((f) => ` ${statusLabel(f.status)} ${f.path}`));
1555
+ }
1556
+ p.note(lines.join("\n"), `${files.length} file${files.length !== 1 ? "s" : ""}`);
1557
+ const choice = await p.select({
1558
+ message: "Stage files for commit:",
1559
+ options: [
1560
+ {
1561
+ label: "Auto-group into commits",
1562
+ value: "autogroup",
1563
+ hint: "LLM groups files into logical commits"
1564
+ },
1565
+ ...stagedFiles.length > 0 ? [{
1566
+ label: "Commit staged files only",
1567
+ value: "staged",
1568
+ hint: `${stagedFiles.length} file${stagedFiles.length !== 1 ? "s" : ""} already staged`
1569
+ }] : [],
1570
+ {
1571
+ label: "Stage all files",
1572
+ value: "all",
1573
+ hint: `${files.length} file${files.length !== 1 ? "s" : ""}`
1574
+ },
1575
+ ...hasChecks ? [{
1576
+ label: "Run checks",
1577
+ value: "checks",
1578
+ hint: "Pre-flight checks from cmint config"
1579
+ }] : [],
1580
+ {
1581
+ label: "Select files...",
1582
+ value: "select"
1583
+ },
1584
+ {
1585
+ label: "Cancel",
1586
+ value: "cancel"
1587
+ }
1588
+ ]
1589
+ });
1590
+ if (p.isCancel(choice) || choice === "cancel") return null;
1591
+ if (choice === "autogroup") return "autogroup";
1592
+ if (choice === "checks") return "checks";
1593
+ if (choice === "staged") return "staged";
1594
+ if (choice === "all") return {
1595
+ files: files.map((f) => f.path),
1596
+ all: true
1597
+ };
1598
+ const selected = await p.multiselect({
1599
+ message: "Select files to stage:",
1600
+ options: sorted.map((f) => ({
1601
+ label: `${statusLabel(f.status)} ${f.path}`,
1602
+ value: f.path
1603
+ })),
1604
+ required: true
1605
+ });
1606
+ if (p.isCancel(selected)) return null;
1607
+ return {
1608
+ files: selected,
1609
+ all: false
1610
+ };
1611
+ }
1612
+ async function showRecoveryMenu(errors, onRetry, onSkipHooks, onRestage, message, rawStderr) {
1613
+ debug("showRecoveryMenu: %d errors", errors.length);
1614
+ let clipboardCopied = false;
1615
+ let showNote = true;
1616
+ while (true) {
1617
+ if (showNote) {
1618
+ p.note(errors.map((e) => ` ${red("•")} [${e.tool}] ${e.message}`).join("\n"), red(bold("Pre-commit hook failed")));
1619
+ showNote = false;
1620
+ }
1621
+ const choice = await p.select({
1622
+ message: "What do you want to do?",
1623
+ options: [
1624
+ {
1625
+ label: clipboardCopied ? `${green("✓")} Copy error report to clipboard` : "Copy error report to clipboard",
1626
+ value: "clipboard",
1627
+ hint: clipboardCopied ? "Copied!" : "Paste into another terminal for an AI agent"
1628
+ },
1629
+ {
1630
+ label: "View full error output",
1631
+ value: "view",
1632
+ hint: "Show the raw stderr from hooks"
1633
+ },
1634
+ {
1635
+ label: "Skip hooks and commit (--no-verify)",
1636
+ value: "skip",
1637
+ hint: "Commit anyway, fix later"
1638
+ },
1639
+ {
1640
+ label: "Re-stage files and retry",
1641
+ value: "restage",
1642
+ hint: "Pick up fixes from another terminal"
1643
+ },
1644
+ {
1645
+ label: "Edit commit message",
1646
+ value: "edit",
1647
+ hint: "Modify the message before retrying"
1648
+ },
1649
+ {
1650
+ label: "Cancel",
1651
+ value: "cancel"
1652
+ }
1653
+ ]
1654
+ });
1655
+ if (p.isCancel(choice)) {
1656
+ debug("showRecoveryMenu: user cancelled");
1657
+ p.outro(yellow("Cancelled. Message cached for --retry."));
1658
+ return "cancelled";
1659
+ }
1660
+ debug("showRecoveryMenu: user chose %s", choice);
1661
+ switch (choice) {
1662
+ case "clipboard":
1663
+ if (await copyToClipboard(rawStderr)) {
1664
+ clipboardCopied = true;
1665
+ p.log.step(green("Copied to clipboard."));
1666
+ } else p.log.warn(red("No clipboard tool found. Install xclip, wl-copy, or xsel."));
1667
+ continue;
1668
+ case "view":
1669
+ p.note(rawStderr.trim() || "(no raw output)", "Full error output");
1670
+ showNote = true;
1671
+ continue;
1672
+ case "skip":
1673
+ p.log.info(yellow("Committing with --no-verify..."));
1674
+ if (await onSkipHooks(message)) {
1675
+ p.outro(green("Committed (hooks skipped)."));
1676
+ return "committed";
1677
+ } else {
1678
+ p.outro(red("Commit failed even with --no-verify."));
1679
+ return "failed";
1680
+ }
1681
+ case "restage":
1682
+ p.log.info(cyan("Re-staging and retrying..."));
1683
+ if (await onRestage()) {
1684
+ p.outro(green("Committed successfully."));
1685
+ return "committed";
1686
+ }
1687
+ showNote = true;
1688
+ continue;
1689
+ case "edit": {
1690
+ const edited = await p.text({
1691
+ message: "Edit commit message:",
1692
+ initialValue: message,
1693
+ validate: (v) => v?.trim() ? void 0 : "Message cannot be empty"
1694
+ });
1695
+ if (p.isCancel(edited)) {
1696
+ p.outro(yellow("Cancelled. Message cached for --retry."));
1697
+ return "cancelled";
1698
+ }
1699
+ if (await onRetry()) {
1700
+ p.outro(green("Committed successfully."));
1701
+ return "committed";
1702
+ } else {
1703
+ p.outro(red("Commit failed again."));
1704
+ return "failed";
1705
+ }
1706
+ }
1707
+ case "cancel":
1708
+ p.outro(dim("Message cached for --retry."));
1709
+ return "cancelled";
1710
+ }
1711
+ }
1343
1712
  }
1344
- //#endregion
1345
- //#region src/ui/grouping.ts
1346
- async function showGroupingConfirmation(groups, excluded) {
1347
- debug("showGroupingConfirmation: %d groups, %d excluded", groups.length, excluded.length);
1348
- const lines = [];
1349
- for (const group of groups) {
1350
- lines.push(bold(group.name));
1351
- lines.push(` ${dim(group.description)}`);
1352
- lines.push(` ${green(String(group.files.length))} file${group.files.length !== 1 ? "s" : ""}`);
1353
- for (const file of group.files) lines.push(` ${dim("")} ${file}`);
1354
- lines.push("");
1355
- }
1356
- if (excluded.length > 0) {
1357
- lines.push(dim(`Excluded: ${excluded.length} file${excluded.length !== 1 ? "s" : ""}`));
1358
- for (const file of excluded) lines.push(` ${dim("")} ${dim(file)}`);
1359
- }
1360
- p.note(lines.join("\n"), "Proposed commit groups");
1361
- const choice = await p.select({
1362
- message: "Proceed with these groupings?",
1363
- options: [{
1364
- label: "Yes, commit all groups",
1365
- value: "yes"
1366
- }, {
1367
- label: "No, cancel",
1368
- value: "no"
1369
- }]
1370
- });
1371
- if (p.isCancel(choice) || choice === "no") {
1372
- debug("showGroupingConfirmation: user cancelled");
1373
- return false;
1713
+ async function showCheckFailureMenu(errors, rawStderr) {
1714
+ debug("showCheckFailureMenu: %d errors", errors.length);
1715
+ let clipboardCopied = false;
1716
+ p.note(errors.map((e) => ` ${red("•")} [${e.tool}] ${e.message}`).join("\n"), red("Pre-commit check failed"));
1717
+ while (true) {
1718
+ const choice = await p.select({
1719
+ message: "What do you want to do?",
1720
+ options: [
1721
+ {
1722
+ label: clipboardCopied ? `${green("")} Copy error report to clipboard` : "Copy error report to clipboard",
1723
+ value: "copy"
1724
+ },
1725
+ {
1726
+ label: "View full error output",
1727
+ value: "view",
1728
+ hint: "Show the raw stderr from checks"
1729
+ },
1730
+ {
1731
+ label: "Skip checks and commit",
1732
+ value: "skip"
1733
+ },
1734
+ {
1735
+ label: "Cancel",
1736
+ value: "cancel"
1737
+ }
1738
+ ]
1739
+ });
1740
+ if (p.isCancel(choice)) {
1741
+ debug("showCheckFailureMenu: user cancelled");
1742
+ return "cancelled";
1743
+ }
1744
+ debug("showCheckFailureMenu: user chose %s", choice);
1745
+ switch (choice) {
1746
+ case "copy":
1747
+ if (await copyToClipboard(rawStderr)) {
1748
+ clipboardCopied = true;
1749
+ p.log.step(green("Copied to clipboard."));
1750
+ } else p.log.warn(red("No clipboard tool found. Install xclip, wl-copy, or xsel."));
1751
+ continue;
1752
+ case "view":
1753
+ p.note(rawStderr.trim() || "(no raw output)", "Full error output");
1754
+ continue;
1755
+ case "skip":
1756
+ p.log.info("Skipping checks and proceeding with commit...");
1757
+ return "skipped";
1758
+ case "cancel":
1759
+ p.outro(dim("Cancelled."));
1760
+ return "cancelled";
1761
+ }
1374
1762
  }
1375
- debug("showGroupingConfirmation: user confirmed");
1376
- return true;
1377
- }
1378
- function showGroupProgress(current, total, groupName) {
1379
- p.log.info(`Commit group ${current} of ${total}: ${cyan(`"${groupName}"`)}`);
1380
1763
  }
1381
1764
  //#endregion
1382
1765
  //#region src/commands/auto-group.ts
1383
1766
  async function runAutoGroupFlow(changedFiles, flags) {
1384
1767
  const { included, excluded } = filterExcludedFiles(changedFiles);
1768
+ if (excluded.length > 0) {
1769
+ debug("Committing %d excluded files upfront:", excluded.length, excluded);
1770
+ const message = buildExcludedFilesMessage(excluded);
1771
+ await resetStaging();
1772
+ await stageFiles(excluded);
1773
+ const headBefore = await getHead();
1774
+ const commitResult = await attemptCommit(message);
1775
+ const headAfter = await getHead();
1776
+ if (commitResult.ok || headBefore !== headAfter) debug("Excluded files committed:", message);
1777
+ else debug("Excluded files commit failed, continuing without them");
1778
+ }
1779
+ if (included.length === 0) {
1780
+ debug("No included files to group, done");
1781
+ outro(green("Done."));
1782
+ return "committed";
1783
+ }
1784
+ if (!flags.noCheck) {
1785
+ const { getRepoRoot } = await Promise.resolve().then(() => git_exports);
1786
+ const repoRoot = await getRepoRoot();
1787
+ const allFiles = included.filter((f) => f.status !== "D").map((f) => f.path);
1788
+ debug("Running user checks on %d files...", allFiles.length);
1789
+ const ck = spinner();
1790
+ ck.start("Running checks...");
1791
+ const checkResults = await runAllChecks(repoRoot, allFiles, 6e4);
1792
+ debug("Check results: ok=%s, count=%d", checkResults.ok, checkResults.results.length);
1793
+ if (!checkResults.ok) {
1794
+ ck.stop(`${checkResults.results.filter((r) => !r.ok).length} check(s) failed`);
1795
+ const rawStderr = checkResults.results.filter((r) => !r.ok).map((r) => `[${r.tool}] ${r.stderr}`).join("\n");
1796
+ if (await showCheckFailureMenu(parseHookErrors(rawStderr), rawStderr) === "cancelled") return "cancelled";
1797
+ } else {
1798
+ ck.stop("All checks passed");
1799
+ if (checkResults.results.length > 0) log.info(checkResults.results.map((r) => ` ${green("✓")} ${r.tool}`).join("\n"));
1800
+ }
1801
+ }
1802
+ const config = await readConfig();
1803
+ const resolvedProvider = config.provider ?? "groq";
1804
+ const provider = isValidProvider(resolvedProvider) ? resolvedProvider : "groq";
1385
1805
  try {
1386
- await getApiKey();
1806
+ await getProviderApiKey(provider);
1387
1807
  debug("API key found");
1388
1808
  } catch {
1389
1809
  debug("No API key found, prompting user");
1390
1810
  const { text: promptText } = await import("@clack/prompts");
1391
1811
  const key = await promptText({
1392
- message: "Enter your Groq API key:",
1393
- placeholder: "gsk_...",
1812
+ message: `Enter your ${formatProviderName(provider)} API key:`,
1813
+ placeholder: provider === "groq" ? "gsk_..." : "...",
1394
1814
  validate: (v) => v?.trim() ? void 0 : "API key is required"
1395
1815
  });
1396
1816
  if (isCancel(key)) {
1397
1817
  outro(dim("Cancelled."));
1398
1818
  return "cancelled";
1399
1819
  }
1400
- await setConfigValue("GROQ_API_KEY", String(key).trim());
1820
+ const configKey = PROVIDER_ENV_KEYS[provider];
1821
+ await setConfigValue(configKey, String(key).trim());
1401
1822
  debug("API key saved to config");
1402
1823
  }
1403
1824
  const s = spinner();
1404
1825
  s.start("Analyzing files...");
1405
- const config = await readConfig();
1406
- const validatedGroups = validateGroups((await generateGroups(included, await getApiKey(), config.model, config.timeout ? parseInt(config.timeout, 10) : void 0)).groups, included);
1826
+ const validatedGroups = validateGroups((await generateGroups(included, await getProviderApiKey(provider), getModelForProvider(config, provider, PROVIDER_CONFIGS[provider].defaultModel), config.timeout ? parseInt(config.timeout, 10) : void 0, provider, config.proxy)).groups, included);
1407
1827
  s.stop("Files analyzed");
1828
+ showChangedFilesTable(included);
1829
+ showGroupingSummary(validatedGroups);
1408
1830
  if (flags.auto) debug("Auto mode: skipping grouping confirmation");
1409
1831
  else if (!await showGroupingConfirmation(validatedGroups, excluded)) {
1410
1832
  outro(dim("Cancelled."));
@@ -1442,9 +1864,9 @@ async function runAutoGroupFlow(changedFiles, flags) {
1442
1864
  }
1443
1865
  const { getRepoRoot } = await Promise.resolve().then(() => git_exports);
1444
1866
  await saveCachedCommit(await getRepoRoot(), message);
1445
- s.start("Committing...");
1867
+ s.start("Running pre-commit hooks...");
1446
1868
  const headBefore = await getHead();
1447
- const commitResult = await attemptCommit(message);
1869
+ const commitResult = await attemptCommit(message, [], createProgressHandler(s));
1448
1870
  const headAfter = await getHead();
1449
1871
  if (commitResult.ok || headBefore !== headAfter) {
1450
1872
  s.stop("Committed successfully.");
@@ -1471,14 +1893,19 @@ async function runAutoGroupFlow(changedFiles, flags) {
1471
1893
  }
1472
1894
  async function generateMessage(diff, hint) {
1473
1895
  const config = await readConfig();
1474
- const apiKey = await getApiKey();
1475
- debug("Generating message with model:", config.model, "type:", config.type);
1896
+ const resolvedProvider = config.provider ?? "groq";
1897
+ const provider = isValidProvider(resolvedProvider) ? resolvedProvider : "groq";
1898
+ const apiKey = await getProviderApiKey(provider);
1899
+ const model = getModelForProvider(config, provider, PROVIDER_CONFIGS[provider].defaultModel);
1900
+ debug("Generating message with provider:", provider, "model:", model, "type:", config.type);
1476
1901
  return generateCommitMessage(diff, {
1477
1902
  apiKey,
1478
- model: config.model,
1903
+ model,
1479
1904
  type: config.type,
1480
1905
  timeout: config.timeout ? parseInt(config.timeout, 10) : void 0,
1481
- hint
1906
+ hint,
1907
+ provider,
1908
+ proxy: config.proxy
1482
1909
  });
1483
1910
  }
1484
1911
  function buildExcludedFilesMessage(files) {
@@ -1491,46 +1918,143 @@ function buildExcludedFilesMessage(files) {
1491
1918
  return "chore: update generated files";
1492
1919
  }
1493
1920
  //#endregion
1494
- //#region src/commands/commit.ts
1495
- async function commitCommand(flags) {
1496
- debug("commitCommand called", { flags });
1497
- await assertGitRepo();
1498
- if (flags.retry) {
1499
- debug("Entering retry mode");
1500
- const { getRepoRoot } = await Promise.resolve().then(() => git_exports);
1501
- const repoRoot = await getRepoRoot();
1502
- debug("Repo root:", repoRoot);
1503
- const cached = await loadCachedCommit(repoRoot);
1504
- if (!cached) {
1505
- debug("No cached commit found");
1506
- outro(red("No cached commit message found. Run cmint without --retry first."));
1507
- process.exit(1);
1921
+ //#region src/commands/commit-utils.ts
1922
+ /** Shared recovery menu factory — avoids repeating the same callback set */
1923
+ function makeRecoveryCallbacks(message) {
1924
+ return {
1925
+ retry: async () => (await attemptCommit(message)).ok,
1926
+ skipHooks: async (msg) => (await attemptCommitNoVerify(msg)).ok,
1927
+ restage: async () => {
1928
+ await stageAll();
1929
+ return (await attemptCommit(message)).ok;
1930
+ },
1931
+ message
1932
+ };
1933
+ }
1934
+ /**
1935
+ * Attempt commit with automatic recovery flow.
1936
+ * Handles the attempt → HEAD check → success (tool checks display)
1937
+ * / failure (recovery menu) pattern.
1938
+ * Caller is responsible for starting the spinner and showing the final outro.
1939
+ */
1940
+ async function commitWithRecovery(message, s, headBefore) {
1941
+ const result = await attemptCommit(message, [], createProgressHandler(s));
1942
+ const headAfter = await getHead();
1943
+ if (result.ok || headBefore !== headAfter) {
1944
+ s.stop("Committed successfully.");
1945
+ const checks = parseToolChecks(result.stderr ?? "");
1946
+ if (checks.length > 0) {
1947
+ const lines = checks.map((c) => ` ${c.ok ? green("✓") : red("✗")} ${c.tool}`);
1948
+ log.info(lines.join("\n"));
1508
1949
  }
1509
- debug("Loaded cached message:", cached.message);
1510
- intro("commit-mint — retry");
1511
- const s = spinner();
1512
- s.start("Retrying commit...");
1513
- const result = await attemptCommit(cached.message);
1514
- s.stop("Attempted commit");
1515
- debug("Retry commit result:", result);
1516
- if (result.ok) {
1517
- const checks = parseToolChecks(result.stderr ?? "");
1518
- if (checks.length > 0) {
1519
- const lines = checks.map((c) => ` ${c.ok ? green("✓") : red("✗")} ${c.tool}`);
1520
- log.info(lines.join("\n"));
1950
+ return "committed";
1951
+ }
1952
+ s.stop("Commit failed.");
1953
+ const errors = parseHookErrors(result.stderr ?? "");
1954
+ const cb = makeRecoveryCallbacks(message);
1955
+ if (await showRecoveryMenu(errors, cb.retry, cb.skipHooks, cb.restage, cb.message, result.stderr ?? "") === "cancelled") return "cancelled";
1956
+ return "committed";
1957
+ }
1958
+ //#endregion
1959
+ //#region src/commands/retry.ts
1960
+ /** Handle --retry mode: load cached message and re-attempt commit */
1961
+ async function handleRetry() {
1962
+ debug("Entering retry mode");
1963
+ const cached = await loadCachedCommit(await getRepoRoot());
1964
+ if (!cached) {
1965
+ outro(red("No cached commit message found. Run cmint without --retry first."));
1966
+ process.exit(1);
1967
+ }
1968
+ intro("🌿 commit-mint — retry");
1969
+ const s = spinner();
1970
+ const headBefore = await getHead();
1971
+ s.start("Running pre-commit hooks...");
1972
+ if (await commitWithRecovery(cached.message, s, headBefore) === "committed") outro(green("Committed successfully."));
1973
+ else process.exit(1);
1974
+ }
1975
+ //#endregion
1976
+ //#region src/commands/staging.ts
1977
+ /** Interactive staging loop for multiple changed files */
1978
+ async function handleStaging(changedFiles, flags) {
1979
+ const repoRoot = await getRepoRoot();
1980
+ const checksAvailable = await detectConfig(repoRoot) !== null;
1981
+ debug("checks available:", checksAvailable);
1982
+ let stagingResult = null;
1983
+ let filesToStage = [];
1984
+ let stageAllFlag = false;
1985
+ let skipStaging = false;
1986
+ let currentFiles = changedFiles;
1987
+ while (true) {
1988
+ stagingResult = await showStagingMenu(currentFiles, checksAvailable);
1989
+ if (stagingResult === "autogroup") {
1990
+ if (flags.message) {
1991
+ outro(red("--message flag is not compatible with auto-group mode."));
1992
+ return null;
1521
1993
  }
1522
- outro(green("Committed successfully."));
1523
- } else {
1524
- const errors = parseHookErrors(result.stderr ?? "");
1525
- debug("Hook errors on retry:", errors.length);
1526
- if (await showRecoveryMenu(errors, async () => (await attemptCommit(cached.message)).ok, async (msg) => (await attemptCommitNoVerify(msg)).ok, async () => {
1527
- await stageAll();
1528
- return (await attemptCommit(cached.message)).ok;
1529
- }, cached.message, result.stderr ?? "") === "cancelled") process.exit(1);
1530
- return;
1994
+ if (await runAutoGroupFlow(currentFiles, flags) !== "committed") process.exit(1);
1995
+ return null;
1996
+ }
1997
+ if (stagingResult === "checks") {
1998
+ await stageAll();
1999
+ const ckSpinner = spinner();
2000
+ ckSpinner.start("Running checks...");
2001
+ const ckResult = await runAllChecks(repoRoot, currentFiles.filter((f) => f.status !== "D").map((f) => f.path), 6e4);
2002
+ if (ckResult.ok) {
2003
+ ckSpinner.stop("All checks passed");
2004
+ for (const r of ckResult.results) if (r.stdout.trim()) log.info(dim(r.stdout.trim()));
2005
+ } else {
2006
+ const failed = ckResult.results.filter((r) => !r.ok);
2007
+ ckSpinner.stop(`${failed.length} check${failed.length !== 1 ? "s" : ""} failed`);
2008
+ for (const r of failed) log.info(r.stderr?.trim() || r.stdout?.trim() || `Check failed: ${r.command}`);
2009
+ }
2010
+ currentFiles = await getChangedFiles();
2011
+ continue;
2012
+ }
2013
+ if (stagingResult === "staged") {
2014
+ skipStaging = true;
2015
+ break;
2016
+ }
2017
+ if (!stagingResult) {
2018
+ outro(dim("Cancelled."));
2019
+ return null;
1531
2020
  }
2021
+ filesToStage = stagingResult.files;
2022
+ stageAllFlag = stagingResult.all;
2023
+ break;
2024
+ }
2025
+ if (!skipStaging) {
2026
+ const s = spinner();
2027
+ s.start(`Staging ${filesToStage.length} file${filesToStage.length !== 1 ? "s" : ""}...`);
2028
+ if (stageAllFlag) await stageAll();
2029
+ else await stageFiles(filesToStage);
2030
+ s.stop("Files staged");
1532
2031
  }
1533
- intro("commit-mint");
2032
+ return {
2033
+ changedFiles: currentFiles,
2034
+ skipStaging
2035
+ };
2036
+ }
2037
+ /** Run user-defined pre-commit checks from cmint config */
2038
+ async function runPreCommitChecks(changedFiles, noCheck) {
2039
+ if (noCheck) return;
2040
+ const checkRoot = await getRepoRoot();
2041
+ const stagedFileList = changedFiles.filter((f) => f.staged && f.status !== "D").map((f) => f.path);
2042
+ if (stagedFileList.length === 0) return;
2043
+ debug("Running user checks on %d staged files...", stagedFileList.length);
2044
+ const checkResults = await runAllChecks(checkRoot, stagedFileList, 6e4);
2045
+ debug("Check results: ok=%s, count=%d", checkResults.ok, checkResults.results.length);
2046
+ if (!checkResults.ok) {
2047
+ const rawStderr = checkResults.results.filter((r) => !r.ok).map((r) => `[${r.tool}] ${r.stderr}`).join("\n");
2048
+ if (await showCheckFailureMenu(parseHookErrors(rawStderr), rawStderr) === "cancelled") process.exit(1);
2049
+ }
2050
+ }
2051
+ //#endregion
2052
+ //#region src/commands/commit.ts
2053
+ async function commitCommand(flags) {
2054
+ debug("commitCommand called", { flags });
2055
+ await assertGitRepo();
2056
+ if (flags.retry) return handleRetry();
2057
+ intro("🌿 commit-mint");
1534
2058
  const status = await getStatusShort();
1535
2059
  debug("Git status:", status || "(empty)");
1536
2060
  if (!status) {
@@ -1553,49 +2077,9 @@ async function commitCommand(flags) {
1553
2077
  await stageFiles([changedFiles[0].path]);
1554
2078
  s.stop("File staged");
1555
2079
  } else {
1556
- const { getRepoRoot } = await Promise.resolve().then(() => git_exports);
1557
- const lintStagedAvailable = await hasLintStagedConfig(await getRepoRoot());
1558
- debug("lint-staged available:", lintStagedAvailable);
1559
- let stagingResult = null;
1560
- let filesToStage = [];
1561
- let stageAllFlag = false;
1562
- while (true) {
1563
- stagingResult = await showStagingMenu(changedFiles, lintStagedAvailable);
1564
- if (stagingResult === "autogroup") {
1565
- if (flags.message) {
1566
- outro(red("--message flag is not compatible with auto-group mode."));
1567
- return;
1568
- }
1569
- if (await runAutoGroupFlow(changedFiles, flags) !== "committed") process.exit(1);
1570
- return;
1571
- }
1572
- if (stagingResult === "lint-staged") {
1573
- await stageAll();
1574
- const lsSpinner = spinner();
1575
- lsSpinner.start("Running lint-staged checks...");
1576
- const lsResult = await runLintStaged();
1577
- if (lsResult.ok) {
1578
- lsSpinner.stop("All lint-staged checks passed");
1579
- if (lsResult.stdout.trim()) log.info(dim(lsResult.stdout.trim()));
1580
- } else {
1581
- lsSpinner.stop("Lint-staged checks failed");
1582
- log.info(lsResult.stderr?.trim() || lsResult.stdout?.trim() || "Unknown error");
1583
- }
1584
- changedFiles = await getChangedFiles();
1585
- continue;
1586
- }
1587
- if (!stagingResult) {
1588
- outro(dim("Cancelled."));
1589
- return;
1590
- }
1591
- filesToStage = stagingResult.files;
1592
- stageAllFlag = stagingResult.all;
1593
- break;
1594
- }
1595
- s.start(`Staging ${filesToStage.length} file${filesToStage.length !== 1 ? "s" : ""}...`);
1596
- if (stageAllFlag) await stageAll();
1597
- else await stageFiles(filesToStage);
1598
- s.stop("Files staged");
2080
+ const result = await handleStaging(changedFiles, flags);
2081
+ if (!result) return;
2082
+ changedFiles = result.changedFiles;
1599
2083
  }
1600
2084
  } catch (err) {
1601
2085
  s.stop(red("Staging failed."));
@@ -1604,6 +2088,8 @@ async function commitCommand(flags) {
1604
2088
  outro(red(`Failed to stage files: ${msg}`));
1605
2089
  process.exit(1);
1606
2090
  }
2091
+ changedFiles = await getChangedFiles();
2092
+ await runPreCommitChecks(changedFiles, flags.noCheck);
1607
2093
  const diffResult = await getStagedDiff();
1608
2094
  if (!diffResult) {
1609
2095
  debug("No staged changes found after staging");
@@ -1614,22 +2100,14 @@ async function commitCommand(flags) {
1614
2100
  debug("All staged files are excluded:", diffResult.excludedFiles);
1615
2101
  const message = buildExcludedFilesMessage(diffResult.excludedFiles);
1616
2102
  log.info(diffResult.excludedFiles.map((f) => ` ${f}`).join("\n"));
1617
- const { getRepoRoot } = await Promise.resolve().then(() => git_exports);
1618
2103
  await saveCachedCommit(await getRepoRoot(), message);
1619
- s.start("Committing...");
1620
- const headBefore = await getHead();
1621
- const result = await attemptCommit(message);
1622
- const headAfter = await getHead();
1623
- if (result.ok || headBefore !== headAfter) {
1624
- s.stop("Committed successfully.");
2104
+ s.start("Running pre-commit hooks...");
2105
+ const result = await commitWithRecovery(message, s, await getHead());
2106
+ if (result === "committed") {
1625
2107
  outro(green("Done."));
1626
2108
  return;
1627
2109
  }
1628
- s.stop("Commit failed.");
1629
- if (await showRecoveryMenu(parseHookErrors(result.stderr ?? ""), async () => (await attemptCommit(message)).ok, async (msg) => (await attemptCommitNoVerify(msg)).ok, async () => {
1630
- await stageAll();
1631
- return (await attemptCommit(message)).ok;
1632
- }, message, result.stderr ?? "") === "cancelled") process.exit(1);
2110
+ if (result === "cancelled") process.exit(1);
1633
2111
  return;
1634
2112
  }
1635
2113
  debug("Staged files:", diffResult.files);
@@ -1640,22 +2118,25 @@ async function commitCommand(flags) {
1640
2118
  debug("Using provided message:", flags.message);
1641
2119
  message = flags.message;
1642
2120
  } else {
2121
+ const config = await readConfig();
2122
+ const provider = isValidProvider(config.provider ?? "groq") ? config.provider : "groq";
1643
2123
  try {
1644
- await getApiKey();
2124
+ await getProviderApiKey(provider);
1645
2125
  debug("API key found");
1646
2126
  } catch {
1647
2127
  debug("No API key found, prompting user");
1648
2128
  const { text: promptText } = await import("@clack/prompts");
2129
+ const configKey = PROVIDER_ENV_KEYS[provider];
1649
2130
  const key = await promptText({
1650
- message: "Enter your Groq API key:",
1651
- placeholder: "gsk_...",
2131
+ message: `Enter your ${formatProviderName(provider)} API key:`,
2132
+ placeholder: provider === "groq" ? "gsk_..." : "...",
1652
2133
  validate: (v) => v?.trim() ? void 0 : "API key is required"
1653
2134
  });
1654
2135
  if (isCancel(key)) {
1655
2136
  outro(dim("Cancelled."));
1656
2137
  return;
1657
2138
  }
1658
- await setConfigValue("GROQ_API_KEY", String(key).trim());
2139
+ await setConfigValue(configKey, String(key).trim());
1659
2140
  debug("API key saved to config");
1660
2141
  }
1661
2142
  s.start("Generating commit message...");
@@ -1678,66 +2159,197 @@ async function commitCommand(flags) {
1678
2159
  return;
1679
2160
  }
1680
2161
  message = reviewed;
1681
- const { getRepoRoot } = await Promise.resolve().then(() => git_exports);
1682
2162
  const repoRoot = await getRepoRoot();
1683
2163
  await saveCachedCommit(repoRoot, message);
1684
2164
  debug("Message cached for repo:", repoRoot);
1685
- s.start("Committing...");
2165
+ s.start("Running pre-commit hooks...");
1686
2166
  const headBefore = await getHead();
1687
2167
  debug("HEAD before commit:", headBefore);
1688
- const result = await attemptCommit(message);
1689
- const headAfter = await getHead();
1690
- debug("HEAD after commit:", headAfter);
2168
+ const result = await commitWithRecovery(message, s, headBefore);
1691
2169
  debug("Commit result:", result);
1692
- if (result.ok || headBefore !== headAfter) {
1693
- s.stop("Committed successfully.");
1694
- const checks = parseToolChecks(result.stderr ?? "");
1695
- if (checks.length > 0) {
1696
- const lines = checks.map((c) => ` ${c.ok ? green("✓") : red("✗")} ${c.tool}`);
1697
- log.info(lines.join("\n"));
1698
- }
2170
+ if (result === "committed") {
1699
2171
  outro(green("Done."));
1700
2172
  return;
1701
2173
  }
1702
- s.stop("Commit failed.");
1703
- debug("Commit failed, showing recovery menu");
1704
- const errors = parseHookErrors(result.stderr ?? "");
1705
- debug("Parsed hook errors:", errors.length, "errors");
1706
- if (await showRecoveryMenu(errors, async () => {
1707
- return (await attemptCommit(message)).ok;
1708
- }, async (msg) => {
1709
- return (await attemptCommitNoVerify(msg)).ok;
1710
- }, async () => {
1711
- await stageAll();
1712
- return (await attemptCommit(message)).ok;
1713
- }, message, result.stderr ?? "") === "cancelled") process.exit(1);
2174
+ if (result === "cancelled") process.exit(1);
1714
2175
  }
1715
2176
  //#endregion
1716
2177
  //#region src/commands/config.ts
1717
- const configCommand = command({
1718
- name: "config",
1719
- parameters: ["<mode>", "<key=value...>"]
1720
- }, async (argv) => {
1721
- const { mode, keyValue } = argv._;
1722
- if (mode === "get") {
1723
- for (const kv of keyValue) {
1724
- const key = kv.split("=")[0];
1725
- const value = await getConfigValue(key);
1726
- console.log(`${key}=${value ?? ""}`);
1727
- }
1728
- return;
2178
+ function maskKey(key) {
2179
+ if (!key) return dim("not set");
2180
+ if (key.length <= 8) return "****";
2181
+ return `${key.slice(0, 4)}${"*".repeat(Math.min(key.length - 8, 20))}${key.slice(-4)}`;
2182
+ }
2183
+ function buildConfigDisplay(config) {
2184
+ const provider = isValidProvider(config.provider ?? "groq") ? config.provider : "groq";
2185
+ const apiKey = config[PROVIDER_ENV_KEYS[provider]];
2186
+ return [
2187
+ `Provider: ${bold(formatProviderName(provider))}`,
2188
+ `API Key: ${maskKey(apiKey)}`,
2189
+ `Model: ${config.model ?? "(none)"}`,
2190
+ `Locale: ${config.locale ?? "en"}`,
2191
+ `Max Length: ${config["max-length"] ?? "100"}`,
2192
+ `Commit Type: ${config.type || dim("(none)")}`,
2193
+ `Timeout: ${config.timeout ?? "10000"}ms`,
2194
+ `Proxy: ${config.proxy || dim("(none)")}`
2195
+ ].join("\n");
2196
+ }
2197
+ function getProvider(config) {
2198
+ return isValidProvider(config.provider ?? "groq") ? config.provider : "groq";
2199
+ }
2200
+ async function promptProvider() {
2201
+ return p.select({
2202
+ message: "Select LLM provider:",
2203
+ options: [
2204
+ {
2205
+ label: "Groq",
2206
+ value: "groq",
2207
+ hint: PROVIDER_CONFIGS.groq.defaultModel
2208
+ },
2209
+ {
2210
+ label: "Cerebras",
2211
+ value: "cerebras",
2212
+ hint: PROVIDER_CONFIGS.cerebras.defaultModel
2213
+ },
2214
+ {
2215
+ label: "Mistral",
2216
+ value: "mistral",
2217
+ hint: PROVIDER_CONFIGS.mistral.defaultModel
2218
+ }
2219
+ ]
2220
+ });
2221
+ }
2222
+ async function promptApiKey(provider) {
2223
+ const keyName = PROVIDER_ENV_KEYS[provider];
2224
+ const result = await p.text({
2225
+ message: `${formatProviderName(provider)} API key:`,
2226
+ placeholder: "Paste your API key",
2227
+ validate: (v) => !v?.trim() ? "API key cannot be empty" : void 0
2228
+ });
2229
+ if (p.isCancel(result)) return result;
2230
+ await writeConfig({ [keyName]: result.toString().trim() });
2231
+ debug("config: %s set", keyName);
2232
+ return result;
2233
+ }
2234
+ async function promptTextSetting(label, configKey, currentValue, validate) {
2235
+ const result = await p.text({
2236
+ message: label,
2237
+ placeholder: currentValue ?? "",
2238
+ initialValue: currentValue ?? "",
2239
+ validate
2240
+ });
2241
+ if (p.isCancel(result)) return result;
2242
+ await writeConfig({ [configKey]: result.toString().trim() });
2243
+ debug("config: %s set to %s", configKey, result);
2244
+ return result;
2245
+ }
2246
+ const requireNumber = (v) => {
2247
+ if (!v?.trim()) return "Value cannot be empty";
2248
+ return Number.isNaN(Number(v)) ? "Must be a number" : void 0;
2249
+ };
2250
+ function getSettingHandlers(config) {
2251
+ const provider = getProvider(config);
2252
+ return {
2253
+ provider: async () => {
2254
+ const result = await promptProvider();
2255
+ if (p.isCancel(result)) return result;
2256
+ await writeConfig({ provider: result });
2257
+ debug("config: provider set to %s", result);
2258
+ },
2259
+ apikey: async () => promptApiKey(provider),
2260
+ model: async () => promptTextSetting("Model ID:", "model", config.model),
2261
+ locale: async () => promptTextSetting("Locale (e.g. en, ja, ko):", "locale", config.locale),
2262
+ maxlen: async () => promptTextSetting("Max commit message length:", "max-length", config["max-length"], requireNumber),
2263
+ type: async () => promptTextSetting("Commit type prefix (e.g. conventional):", "type", config.type),
2264
+ timeout: async () => promptTextSetting("Timeout (ms):", "timeout", config.timeout, requireNumber),
2265
+ proxy: async () => promptTextSetting("Proxy URL:", "proxy", config.proxy)
2266
+ };
2267
+ }
2268
+ async function handleEditSetting(setting, config) {
2269
+ const handler = getSettingHandlers(config)[setting];
2270
+ if (!handler) return false;
2271
+ const result = await handler();
2272
+ return !p.isCancel(result);
2273
+ }
2274
+ async function editSettingsLoop(initialConfig) {
2275
+ let config = initialConfig;
2276
+ while (true) {
2277
+ config = await readConfig();
2278
+ const provider = getProvider(config);
2279
+ const setting = await p.select({
2280
+ message: "Select a setting to edit:",
2281
+ options: [
2282
+ {
2283
+ label: `LLM Provider ${dim(`(${formatProviderName(provider)})`)}`,
2284
+ value: "provider"
2285
+ },
2286
+ {
2287
+ label: `API Key ${dim(`(for ${formatProviderName(provider)})`)}`,
2288
+ value: "apikey"
2289
+ },
2290
+ {
2291
+ label: `Model ${dim(`(${config.model ?? "(none)"})`)}`,
2292
+ value: "model"
2293
+ },
2294
+ {
2295
+ label: `Locale ${dim(`(${config.locale ?? "en"})`)}`,
2296
+ value: "locale"
2297
+ },
2298
+ {
2299
+ label: `Max commit length ${dim(`(${config["max-length"] ?? "100"})`)}`,
2300
+ value: "maxlen"
2301
+ },
2302
+ {
2303
+ label: `Commit type prefix ${dim(`(${config.type || "(none)"})`)}`,
2304
+ value: "type"
2305
+ },
2306
+ {
2307
+ label: `Timeout (ms) ${dim(`(${config.timeout ?? "10000"})`)}`,
2308
+ value: "timeout"
2309
+ },
2310
+ {
2311
+ label: `Proxy URL ${dim(`(${config.proxy || "(none)"})`)}`,
2312
+ value: "proxy"
2313
+ },
2314
+ {
2315
+ label: "Done editing",
2316
+ value: "done"
2317
+ }
2318
+ ]
2319
+ });
2320
+ if (p.isCancel(setting) || setting === "done") break;
2321
+ if (await handleEditSetting(setting, config)) p.log.success(green("Updated."));
1729
2322
  }
1730
- if (mode === "set") {
1731
- for (const kv of keyValue) {
1732
- const [key, ...rest] = kv.split("=");
1733
- await setConfigValue(key, rest.join("="));
2323
+ }
2324
+ async function configCommand() {
2325
+ debug("configCommand: starting");
2326
+ p.intro(bold("🌿 commit-mint config"));
2327
+ while (true) {
2328
+ const config = await readConfig();
2329
+ p.note(buildConfigDisplay(config), "commit-mint config");
2330
+ const action = await p.select({
2331
+ message: "What would you like to do?",
2332
+ options: [{
2333
+ label: "Edit settings",
2334
+ value: "edit"
2335
+ }, {
2336
+ label: "Done",
2337
+ value: "done"
2338
+ }]
2339
+ });
2340
+ if (p.isCancel(action)) {
2341
+ debug("configCommand: cancelled at main menu");
2342
+ p.outro(dim("Cancelled."));
2343
+ return;
1734
2344
  }
1735
- console.log("Config updated.");
1736
- return;
2345
+ if (action === "done") {
2346
+ debug("configCommand: done");
2347
+ p.outro("Config saved.");
2348
+ return;
2349
+ }
2350
+ await editSettingsLoop(config);
1737
2351
  }
1738
- console.error(`Unknown config mode: ${mode}. Use "get" or "set".`);
1739
- process.exit(1);
1740
- });
2352
+ }
1741
2353
  //#endregion
1742
2354
  //#region src/cli.ts
1743
2355
  const { version } = package_default;
@@ -1779,9 +2391,17 @@ cli({
1779
2391
  description: "Enable debug output",
1780
2392
  alias: "d",
1781
2393
  default: false
2394
+ },
2395
+ noCheck: {
2396
+ type: Boolean,
2397
+ description: "Skip user-defined pre-commit checks",
2398
+ alias: "N",
2399
+ default: false
1782
2400
  }
1783
2401
  },
1784
- commands: [configCommand]
2402
+ commands: [command({ name: "config" }, async () => {
2403
+ await configCommand();
2404
+ })]
1785
2405
  }, (argv) => {
1786
2406
  setDebug(argv.flags.debug);
1787
2407
  if (argv.flags.review) reviewCommand();