@kyubiware/commit-mint 0.4.2 → 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.2",
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,29 @@ 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];
147
190
  }
148
- debug("getApiKey: not found");
149
- throw new Error("Please set your Groq API key via `cmint config set GROQ_API_KEY=<your token>`");
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;
150
196
  }
151
197
  //#endregion
152
198
  //#region src/services/hooks.ts
@@ -521,50 +567,6 @@ async function attemptCommitNoVerify(message, onProgress) {
521
567
  return attemptCommit(message, ["--no-verify"], onProgress);
522
568
  }
523
569
  //#endregion
524
- //#region src/services/lint-staged.ts
525
- const CONFIG_FILES = [
526
- ".lintstagedrc",
527
- ".lintstagedrc.json",
528
- ".lintstagedrc.yaml",
529
- ".lintstagedrc.yml",
530
- ".lintstagedrc.mjs",
531
- ".lintstagedrc.cjs",
532
- "lint-staged.config.mjs",
533
- "lint-staged.config.cjs",
534
- "lint-staged.config.js"
535
- ];
536
- async function hasLintStagedConfig(repoRoot) {
537
- debug("hasLintStagedConfig: checking in %s", repoRoot);
538
- for (const file of CONFIG_FILES) {
539
- const path = join(repoRoot, file);
540
- try {
541
- await access(path, constants.F_OK);
542
- debug("hasLintStagedConfig: found %s", file);
543
- return true;
544
- } catch {}
545
- }
546
- const packageJsonPath = join(repoRoot, "package.json");
547
- try {
548
- const raw = await readFile(packageJsonPath, "utf8");
549
- if ("lint-staged" in JSON.parse(raw)) {
550
- debug("hasLintStagedConfig: found lint-staged in package.json");
551
- return true;
552
- }
553
- } catch {}
554
- debug("hasLintStagedConfig: no config found");
555
- return false;
556
- }
557
- async function runLintStaged() {
558
- debug("runLintStaged: starting npx lint-staged");
559
- const { failed, stdout, stderr } = await execa("npx", ["lint-staged"], { reject: false });
560
- debug("runLintStaged: finished, failed=%s", failed);
561
- return {
562
- ok: !failed,
563
- stdout,
564
- stderr
565
- };
566
- }
567
- //#endregion
568
570
  //#region src/services/clipboard.ts
569
571
  async function copyToClipboard(content) {
570
572
  for (const [cmd, args] of [
@@ -604,181 +606,14 @@ async function copyToClipboard(content) {
604
606
  return false;
605
607
  }
606
608
  //#endregion
607
- //#region src/ui/menu.ts
608
- async function showStagingMenu(files, hasLintStaged) {
609
- debug("showStagingMenu: %d files", files.length);
610
- const statusLabel = (status) => {
611
- switch (status) {
612
- case "M": return yellow("M");
613
- case "A": return green("A");
614
- case "D": return red("D");
615
- case "?":
616
- case "??": return cyan("?");
617
- default: return dim(status);
618
- }
619
- };
620
- const sorted = [...files].sort((a, b) => {
621
- if (a.staged !== b.staged) return a.staged ? -1 : 1;
622
- return a.path.localeCompare(b.path);
623
- });
624
- const stagedFiles = sorted.filter((f) => f.staged);
625
- const unstagedFiles = sorted.filter((f) => !f.staged);
626
- const lines = [];
627
- if (stagedFiles.length > 0) lines.push(green(bold("Staged:")), ...stagedFiles.map((f) => ` ${statusLabel(f.status)} ${f.path}`));
628
- if (unstagedFiles.length > 0) {
629
- if (lines.length > 0) lines.push("");
630
- lines.push(yellow(bold("Changed:")), ...unstagedFiles.map((f) => ` ${statusLabel(f.status)} ${f.path}`));
631
- }
632
- p.note(lines.join("\n"), `${files.length} file${files.length !== 1 ? "s" : ""}`);
633
- const choice = await p.select({
634
- message: "Stage files for commit:",
635
- options: [
636
- {
637
- label: "Auto-group into commits",
638
- value: "autogroup",
639
- hint: "LLM groups files into logical commits"
640
- },
641
- {
642
- label: "Stage all files",
643
- value: "all",
644
- hint: `${files.length} file${files.length !== 1 ? "s" : ""}`
645
- },
646
- ...hasLintStaged ? [{
647
- label: "Run lint-staged checks",
648
- value: "lint-staged",
649
- hint: "Pre-flight checks on all changed files"
650
- }] : [],
651
- {
652
- label: "Select files...",
653
- value: "select"
654
- },
655
- {
656
- label: "Cancel",
657
- value: "cancel"
658
- }
659
- ]
660
- });
661
- if (p.isCancel(choice) || choice === "cancel") return null;
662
- if (choice === "autogroup") return "autogroup";
663
- if (choice === "lint-staged") return "lint-staged";
664
- if (choice === "all") return {
665
- files: files.map((f) => f.path),
666
- all: true
667
- };
668
- const selected = await p.multiselect({
669
- message: "Select files to stage:",
670
- options: sorted.map((f) => ({
671
- label: `${statusLabel(f.status)} ${f.path}`,
672
- value: f.path
673
- })),
674
- required: true
675
- });
676
- if (p.isCancel(selected)) return null;
677
- return {
678
- files: selected,
679
- all: false
680
- };
681
- }
682
- async function showRecoveryMenu(errors, onRetry, onSkipHooks, onRestage, message, rawStderr) {
683
- debug("showRecoveryMenu: %d errors", errors.length);
684
- let clipboardCopied = false;
685
- let showNote = true;
686
- while (true) {
687
- if (showNote) {
688
- p.note(errors.map((e) => ` ${red("•")} [${e.tool}] ${e.message}`).join("\n"), red(bold("Pre-commit hook failed")));
689
- showNote = false;
690
- }
691
- const choice = await p.select({
692
- message: "What do you want to do?",
693
- options: [
694
- {
695
- label: clipboardCopied ? `${green("✓")} Copy error report to clipboard` : "Copy error report to clipboard",
696
- value: "clipboard",
697
- hint: clipboardCopied ? "Copied!" : "Paste into another terminal for an AI agent"
698
- },
699
- {
700
- label: "Skip hooks and commit (--no-verify)",
701
- value: "skip",
702
- hint: "Commit anyway, fix later"
703
- },
704
- {
705
- label: "Re-stage files and retry",
706
- value: "restage",
707
- hint: "Pick up fixes from another terminal"
708
- },
709
- {
710
- label: "Edit commit message",
711
- value: "edit",
712
- hint: "Modify the message before retrying"
713
- },
714
- {
715
- label: "Cancel",
716
- value: "cancel"
717
- }
718
- ]
719
- });
720
- if (p.isCancel(choice)) {
721
- debug("showRecoveryMenu: user cancelled");
722
- p.outro(yellow("Cancelled. Message cached for --retry."));
723
- return "cancelled";
724
- }
725
- debug("showRecoveryMenu: user chose %s", choice);
726
- switch (choice) {
727
- case "clipboard":
728
- if (await copyToClipboard(rawStderr)) {
729
- clipboardCopied = true;
730
- p.log.step(green("Copied to clipboard."));
731
- } else p.log.warn(red("No clipboard tool found. Install xclip, wl-copy, or xsel."));
732
- continue;
733
- case "skip":
734
- p.log.info(yellow("Committing with --no-verify..."));
735
- if (await onSkipHooks(message)) {
736
- p.outro(green("Committed (hooks skipped)."));
737
- return "committed";
738
- } else {
739
- p.outro(red("Commit failed even with --no-verify."));
740
- return "failed";
741
- }
742
- case "restage":
743
- p.log.info(cyan("Re-staging and retrying..."));
744
- if (await onRestage()) {
745
- p.outro(green("Committed successfully."));
746
- return "committed";
747
- }
748
- showNote = true;
749
- continue;
750
- case "edit": {
751
- const edited = await p.text({
752
- message: "Edit commit message:",
753
- initialValue: message,
754
- validate: (v) => v?.trim() ? void 0 : "Message cannot be empty"
755
- });
756
- if (p.isCancel(edited)) {
757
- p.outro(yellow("Cancelled. Message cached for --retry."));
758
- return "cancelled";
759
- }
760
- if (await onRetry()) {
761
- p.outro(green("Committed successfully."));
762
- return "committed";
763
- } else {
764
- p.outro(red("Commit failed again."));
765
- return "failed";
766
- }
767
- }
768
- case "cancel":
769
- p.outro(dim("Message cached for --retry."));
770
- return "cancelled";
771
- }
772
- }
773
- }
774
- //#endregion
775
609
  //#region src/services/ai.ts
776
610
  const MAX_DIFF_CHARS = 2e4;
777
- function mapGroqError$1(error) {
778
- if (error instanceof Groq.AuthenticationError) return /* @__PURE__ */ new Error("Invalid GROQ_API_KEY. Run: cmint config set GROQ_API_KEY=<key>");
779
- if (error instanceof Groq.RateLimitError) return /* @__PURE__ */ new Error("Rate limited by Groq. Please wait and try again.");
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.`);
780
615
  if (error instanceof Groq.APIConnectionTimeoutError) return /* @__PURE__ */ new Error("Request timed out. Check your network or try a smaller diff.");
781
- if (error instanceof Groq.APIError) return /* @__PURE__ */ new Error(`Groq API error: ${error.message}`);
616
+ if (error instanceof Groq.APIError) return /* @__PURE__ */ new Error(`${label} API error: ${error.message}`);
782
617
  return /* @__PURE__ */ new Error(`Unexpected error: ${error instanceof Error ? error.message : String(error)}`);
783
618
  }
784
619
  const CONVENTIONAL_COMMIT_REGEX = /^(build|chore|ci|docs|feat|fix|perf|refactor|revert|style|test)(\(.+\))?!?: .+$/;
@@ -863,13 +698,16 @@ function extractContentText(content) {
863
698
  return "";
864
699
  }
865
700
  async function generateCommitMessage(diff, options) {
866
- debug("generateCommitMessage: model=%s, type=%s, hint=%s", options.model ?? "default", options.type ?? "none", options.hint ?? "none");
867
701
  const timeoutMs = options.timeout ?? 6e4;
868
702
  debug("Timeout: %d ms", timeoutMs);
869
- const client = new Groq({
703
+ const { client, model } = createProvider({
704
+ provider: options.provider ?? "groq",
870
705
  apiKey: options.apiKey,
871
- timeout: timeoutMs
706
+ modelOverride: options.model,
707
+ timeout: timeoutMs,
708
+ baseURLOverride: options.proxy
872
709
  });
710
+ debug("generateCommitMessage: model=%s, type=%s, hint=%s", model, options.type ?? "none", options.hint ?? "none");
873
711
  const compressedDiff = compressDiff(diff);
874
712
  const statSummary = buildStatSummary(diff);
875
713
  const systemPrompt = buildSystemPrompt(options.type);
@@ -879,9 +717,9 @@ async function generateCommitMessage(diff, options) {
879
717
  debug("User prompt length: %d chars", userPrompt.length);
880
718
  async function callAI(strictSystemPrompt) {
881
719
  const callStart = Date.now();
882
- 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);
883
721
  try {
884
- 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);
885
723
  const completion = await client.chat.completions.create({
886
724
  messages: [{
887
725
  role: "system",
@@ -890,7 +728,7 @@ async function generateCommitMessage(diff, options) {
890
728
  role: "user",
891
729
  content: userPrompt
892
730
  }],
893
- model: options.model ?? "openai/gpt-oss-20b",
731
+ model,
894
732
  temperature: .3,
895
733
  ...isReasoningModel ? { max_completion_tokens: 1024 } : { max_tokens: 1024 },
896
734
  reasoning_format: "parsed"
@@ -936,7 +774,7 @@ async function generateCommitMessage(diff, options) {
936
774
  return message;
937
775
  } catch (error) {
938
776
  debug("AI error: %s", error instanceof Error ? error.message : String(error));
939
- throw mapGroqError$1(error);
777
+ throw mapGroqError(error, options.provider ? formatProviderName(options.provider) : void 0);
940
778
  }
941
779
  }
942
780
  //#endregion
@@ -979,9 +817,12 @@ function buildReviewPrompt(diff, files, statSummary) {
979
817
  async function generateCodeReview(diff, files, options) {
980
818
  debug("generateCodeReview: model=%s, files=%d", options.model ?? "default", files.length);
981
819
  const timeoutMs = options.timeout ?? 6e4;
982
- const client = new Groq({
820
+ const { client, model } = createProvider({
821
+ provider: options.provider ?? "groq",
983
822
  apiKey: options.apiKey,
984
- timeout: timeoutMs
823
+ modelOverride: options.model,
824
+ timeout: timeoutMs,
825
+ baseURLOverride: options.proxy
985
826
  });
986
827
  const compressedDiff = compressDiff(diff);
987
828
  const statSummary = buildStatSummary(diff);
@@ -997,7 +838,7 @@ async function generateCodeReview(diff, files, options) {
997
838
  role: "user",
998
839
  content: userPrompt
999
840
  }],
1000
- model: options.model ?? "openai/gpt-oss-20b",
841
+ model,
1001
842
  temperature: .3,
1002
843
  max_tokens: 4096
1003
844
  });
@@ -1018,7 +859,7 @@ async function generateCodeReview(diff, files, options) {
1018
859
  return content;
1019
860
  } catch (error) {
1020
861
  debug("generateCodeReview error: %s", error instanceof Error ? error.message : String(error));
1021
- throw mapGroqError$1(error);
862
+ throw mapGroqError(error, options.provider ? formatProviderName(options.provider) : void 0);
1022
863
  }
1023
864
  }
1024
865
  //#endregion
@@ -1039,7 +880,7 @@ async function reviewCommand() {
1039
880
  outro(dim("Staged files are all excluded from review."));
1040
881
  return;
1041
882
  }
1042
- intro("commit-mint — code review");
883
+ intro("🌿 commit-mint — code review");
1043
884
  log.info(diffResult.files.map((f) => ` ${f}`).join("\n"));
1044
885
  const report = await isOpenCodeAvailable() ? await reviewWithOpenCode(diffResult.diff, diffResult.files) : await reviewWithGroq(diffResult.diff, diffResult.files);
1045
886
  if (report !== "NO_ISSUES_FOUND" && report.trim().length > 0) {
@@ -1074,14 +915,19 @@ async function isOpenCodeAvailable() {
1074
915
  }
1075
916
  }
1076
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);
1077
922
  const s = spinner();
1078
- s.start("Reviewing with Groq...");
923
+ s.start(`Reviewing with ${formatProviderName(provider)}...`);
1079
924
  try {
1080
- const config = await readConfig();
1081
925
  const report = await generateCodeReview(diff, files, {
1082
- apiKey: await getApiKey(),
1083
- model: config.model,
1084
- 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
1085
931
  });
1086
932
  s.stop("Review complete");
1087
933
  return report;
@@ -1137,8 +983,10 @@ async function runCodeReview() {
1137
983
  return;
1138
984
  }
1139
985
  const opencodeAvailable = await isOpenCodeAvailable();
986
+ const config = await readConfig();
987
+ const provider = isValidProvider(config.provider ?? "groq") ? config.provider : "groq";
1140
988
  const s = spinner();
1141
- s.start(opencodeAvailable ? "Running OpenCode review..." : "Running Groq review...");
989
+ s.start(opencodeAvailable ? "Running OpenCode review..." : `Running ${formatProviderName(provider)} review...`);
1142
990
  try {
1143
991
  const report = opencodeAvailable ? await reviewWithOpenCode(diffResult.diff, diffResult.files) : await reviewWithGroq(diffResult.diff, diffResult.files);
1144
992
  s.stop("Review complete");
@@ -1254,54 +1102,243 @@ async function loadCachedCommit(repoPath) {
1254
1102
  }
1255
1103
  }
1256
1104
  //#endregion
1257
- //#region src/services/grouping.ts
1258
- function mapGroqError(error) {
1259
- if (error instanceof Groq.AuthenticationError) return /* @__PURE__ */ new Error("Invalid GROQ_API_KEY. Run: cmint config set GROQ_API_KEY=<key>");
1260
- if (error instanceof Groq.RateLimitError) return /* @__PURE__ */ new Error("Rate limited by Groq. Please wait and try again.");
1261
- if (error instanceof Groq.APIConnectionTimeoutError) return /* @__PURE__ */ new Error("Request timed out. Check your network and try again.");
1262
- if (error instanceof Groq.APIError) return /* @__PURE__ */ new Error(`Groq API error: ${error.message}`);
1263
- 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;
1264
1136
  }
1265
- function matchesExcludePattern(filePath, pattern) {
1266
- if (pattern === filePath) return true;
1267
- if (pattern.endsWith("/**")) {
1268
- const prefix = pattern.slice(0, -3);
1269
- return filePath === prefix || filePath.startsWith(`${prefix}/`);
1270
- }
1271
- if (pattern.startsWith("*.")) {
1272
- const suffix = pattern.slice(1);
1273
- return filePath.endsWith(suffix);
1274
- }
1275
- return false;
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;
1276
1160
  }
1277
- /** Lockfiles that should be kept when their companion manifest is present */
1278
- const LOCKFILE_COMPANIONS = {
1279
- "package-lock.json": "package.json",
1280
- "pnpm-lock.yaml": "package.json",
1281
- "yarn.lock": "package.json"
1282
- };
1283
- function filterExcludedFiles(files) {
1284
- const patterns = getDefaultExcludes();
1285
- const included = [];
1286
- const excluded = [];
1287
- const filePaths = new Set(files.map((f) => f.path));
1288
- for (const file of files) if (patterns.some((pattern) => matchesExcludePattern(file.path, pattern))) excluded.push(file);
1289
- else included.push(file);
1290
- const stillExcluded = [];
1291
- for (const file of excluded) {
1292
- const companion = LOCKFILE_COMPANIONS[file.path];
1293
- if (companion && filePaths.has(companion)) included.push(file);
1294
- else stillExcluded.push(file.path);
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
+ };
1295
1201
  }
1296
- debug("filterExcludedFiles: %d included, %d excluded", included.length, stillExcluded.length);
1297
- return {
1298
- included,
1299
- excluded: stillExcluded
1300
- };
1301
1202
  }
1302
- function statusIndicator(status) {
1303
- switch (status) {
1304
- case "M": return "modified";
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
+ };
1297
+ }
1298
+ //#endregion
1299
+ //#region src/services/grouping.ts
1300
+ function matchesExcludePattern(filePath, pattern) {
1301
+ if (pattern === filePath) return true;
1302
+ if (pattern.endsWith("/**")) {
1303
+ const prefix = pattern.slice(0, -3);
1304
+ return filePath === prefix || filePath.startsWith(`${prefix}/`);
1305
+ }
1306
+ if (pattern.startsWith("*.")) {
1307
+ const suffix = pattern.slice(1);
1308
+ return filePath.endsWith(suffix);
1309
+ }
1310
+ return false;
1311
+ }
1312
+ /** Lockfiles that should be kept when their companion manifest is present */
1313
+ const LOCKFILE_COMPANIONS = {
1314
+ "package-lock.json": "package.json",
1315
+ "pnpm-lock.yaml": "package.json",
1316
+ "yarn.lock": "package.json",
1317
+ "bun.lock": "package.json",
1318
+ "bun.lockb": "package.json"
1319
+ };
1320
+ function filterExcludedFiles(files) {
1321
+ const patterns = getDefaultExcludes();
1322
+ const included = [];
1323
+ const excluded = [];
1324
+ const filePaths = new Set(files.map((f) => f.path));
1325
+ for (const file of files) if (patterns.some((pattern) => matchesExcludePattern(file.path, pattern))) excluded.push(file);
1326
+ else included.push(file);
1327
+ const stillExcluded = [];
1328
+ for (const file of excluded) {
1329
+ const companion = LOCKFILE_COMPANIONS[file.path];
1330
+ if (companion && filePaths.has(companion)) included.push(file);
1331
+ else stillExcluded.push(file.path);
1332
+ }
1333
+ debug("filterExcludedFiles: %d included, %d excluded", included.length, stillExcluded.length);
1334
+ return {
1335
+ included,
1336
+ excluded: stillExcluded
1337
+ };
1338
+ }
1339
+ function statusIndicator(status) {
1340
+ switch (status) {
1341
+ case "M": return "modified";
1305
1342
  case "A": return "added";
1306
1343
  case "D": return "deleted";
1307
1344
  case "R": return "renamed";
@@ -1352,7 +1389,7 @@ function parseGroupingResponse(content) {
1352
1389
  });
1353
1390
  return rawGroups;
1354
1391
  }
1355
- async function generateGroups(files, apiKey, model, timeout) {
1392
+ async function generateGroups(files, apiKey, model, timeout, provider, proxy) {
1356
1393
  debug("generateGroups: %d files, model=%s", files.length, model ?? "default");
1357
1394
  const { included, excluded } = filterExcludedFiles(files);
1358
1395
  if (included.length === 0) {
@@ -1367,9 +1404,12 @@ async function generateGroups(files, apiKey, model, timeout) {
1367
1404
  const userPrompt = buildGroupingUserPrompt(summary);
1368
1405
  debug("File summary:\n%s", summary);
1369
1406
  debug("User prompt length: %d chars", userPrompt.length);
1370
- const client = new Groq({
1407
+ const { client, model: resolvedModel } = createProvider({
1408
+ provider: provider ?? "groq",
1371
1409
  apiKey,
1372
- timeout: timeout ?? 6e4
1410
+ modelOverride: model,
1411
+ timeout: timeout ?? 6e4,
1412
+ baseURLOverride: proxy
1373
1413
  });
1374
1414
  try {
1375
1415
  const completion = await client.chat.completions.create({
@@ -1380,7 +1420,7 @@ async function generateGroups(files, apiKey, model, timeout) {
1380
1420
  role: "user",
1381
1421
  content: userPrompt
1382
1422
  }],
1383
- model: model ?? "openai/gpt-oss-20b",
1423
+ model: resolvedModel,
1384
1424
  temperature: .3,
1385
1425
  max_tokens: 2048
1386
1426
  });
@@ -1399,7 +1439,7 @@ async function generateGroups(files, apiKey, model, timeout) {
1399
1439
  };
1400
1440
  } catch (error) {
1401
1441
  debug("generateGroups error: %s", error instanceof Error ? error.message : String(error));
1402
- throw mapGroqError(error);
1442
+ throw mapGroqError(error, provider ? formatProviderName(provider) : void 0);
1403
1443
  }
1404
1444
  }
1405
1445
  function validateGroups(groups, allFiles) {
@@ -1465,33 +1505,328 @@ async function showGroupingConfirmation(groups, excluded) {
1465
1505
  function showGroupProgress(current, total, groupName) {
1466
1506
  p.log.info(`Commit group ${current} of ${total}: ${cyan(`"${groupName}"`)}`);
1467
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
+ }
1712
+ }
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
+ }
1762
+ }
1763
+ }
1468
1764
  //#endregion
1469
1765
  //#region src/commands/auto-group.ts
1470
1766
  async function runAutoGroupFlow(changedFiles, flags) {
1471
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";
1472
1805
  try {
1473
- await getApiKey();
1806
+ await getProviderApiKey(provider);
1474
1807
  debug("API key found");
1475
1808
  } catch {
1476
1809
  debug("No API key found, prompting user");
1477
1810
  const { text: promptText } = await import("@clack/prompts");
1478
1811
  const key = await promptText({
1479
- message: "Enter your Groq API key:",
1480
- placeholder: "gsk_...",
1812
+ message: `Enter your ${formatProviderName(provider)} API key:`,
1813
+ placeholder: provider === "groq" ? "gsk_..." : "...",
1481
1814
  validate: (v) => v?.trim() ? void 0 : "API key is required"
1482
1815
  });
1483
1816
  if (isCancel(key)) {
1484
1817
  outro(dim("Cancelled."));
1485
1818
  return "cancelled";
1486
1819
  }
1487
- await setConfigValue("GROQ_API_KEY", String(key).trim());
1820
+ const configKey = PROVIDER_ENV_KEYS[provider];
1821
+ await setConfigValue(configKey, String(key).trim());
1488
1822
  debug("API key saved to config");
1489
1823
  }
1490
1824
  const s = spinner();
1491
1825
  s.start("Analyzing files...");
1492
- const config = await readConfig();
1493
- 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);
1494
1827
  s.stop("Files analyzed");
1828
+ showChangedFilesTable(included);
1829
+ showGroupingSummary(validatedGroups);
1495
1830
  if (flags.auto) debug("Auto mode: skipping grouping confirmation");
1496
1831
  else if (!await showGroupingConfirmation(validatedGroups, excluded)) {
1497
1832
  outro(dim("Cancelled."));
@@ -1558,14 +1893,19 @@ async function runAutoGroupFlow(changedFiles, flags) {
1558
1893
  }
1559
1894
  async function generateMessage(diff, hint) {
1560
1895
  const config = await readConfig();
1561
- const apiKey = await getApiKey();
1562
- 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);
1563
1901
  return generateCommitMessage(diff, {
1564
1902
  apiKey,
1565
- model: config.model,
1903
+ model,
1566
1904
  type: config.type,
1567
1905
  timeout: config.timeout ? parseInt(config.timeout, 10) : void 0,
1568
- hint
1906
+ hint,
1907
+ provider,
1908
+ proxy: config.proxy
1569
1909
  });
1570
1910
  }
1571
1911
  function buildExcludedFilesMessage(files) {
@@ -1578,46 +1918,143 @@ function buildExcludedFilesMessage(files) {
1578
1918
  return "chore: update generated files";
1579
1919
  }
1580
1920
  //#endregion
1581
- //#region src/commands/commit.ts
1582
- async function commitCommand(flags) {
1583
- debug("commitCommand called", { flags });
1584
- await assertGitRepo();
1585
- if (flags.retry) {
1586
- debug("Entering retry mode");
1587
- const { getRepoRoot } = await Promise.resolve().then(() => git_exports);
1588
- const repoRoot = await getRepoRoot();
1589
- debug("Repo root:", repoRoot);
1590
- const cached = await loadCachedCommit(repoRoot);
1591
- if (!cached) {
1592
- debug("No cached commit found");
1593
- outro(red("No cached commit message found. Run cmint without --retry first."));
1594
- 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"));
1595
1949
  }
1596
- debug("Loaded cached message:", cached.message);
1597
- intro("commit-mint — retry");
1598
- const s = spinner();
1599
- s.start("Running pre-commit hooks...");
1600
- const result = await attemptCommit(cached.message, [], createProgressHandler(s));
1601
- s.stop("Attempted commit");
1602
- debug("Retry commit result:", result);
1603
- if (result.ok) {
1604
- const checks = parseToolChecks(result.stderr ?? "");
1605
- if (checks.length > 0) {
1606
- const lines = checks.map((c) => ` ${c.ok ? green("✓") : red("✗")} ${c.tool}`);
1607
- 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;
1608
1993
  }
1609
- outro(green("Committed successfully."));
1610
- } else {
1611
- const errors = parseHookErrors(result.stderr ?? "");
1612
- debug("Hook errors on retry:", errors.length);
1613
- if (await showRecoveryMenu(errors, async () => (await attemptCommit(cached.message)).ok, async (msg) => (await attemptCommitNoVerify(msg)).ok, async () => {
1614
- await stageAll();
1615
- return (await attemptCommit(cached.message)).ok;
1616
- }, cached.message, result.stderr ?? "") === "cancelled") process.exit(1);
1617
- 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;
1618
2012
  }
2013
+ if (stagingResult === "staged") {
2014
+ skipStaging = true;
2015
+ break;
2016
+ }
2017
+ if (!stagingResult) {
2018
+ outro(dim("Cancelled."));
2019
+ return null;
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");
1619
2031
  }
1620
- 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");
1621
2058
  const status = await getStatusShort();
1622
2059
  debug("Git status:", status || "(empty)");
1623
2060
  if (!status) {
@@ -1640,49 +2077,9 @@ async function commitCommand(flags) {
1640
2077
  await stageFiles([changedFiles[0].path]);
1641
2078
  s.stop("File staged");
1642
2079
  } else {
1643
- const { getRepoRoot } = await Promise.resolve().then(() => git_exports);
1644
- const lintStagedAvailable = await hasLintStagedConfig(await getRepoRoot());
1645
- debug("lint-staged available:", lintStagedAvailable);
1646
- let stagingResult = null;
1647
- let filesToStage = [];
1648
- let stageAllFlag = false;
1649
- while (true) {
1650
- stagingResult = await showStagingMenu(changedFiles, lintStagedAvailable);
1651
- if (stagingResult === "autogroup") {
1652
- if (flags.message) {
1653
- outro(red("--message flag is not compatible with auto-group mode."));
1654
- return;
1655
- }
1656
- if (await runAutoGroupFlow(changedFiles, flags) !== "committed") process.exit(1);
1657
- return;
1658
- }
1659
- if (stagingResult === "lint-staged") {
1660
- await stageAll();
1661
- const lsSpinner = spinner();
1662
- lsSpinner.start("Running lint-staged checks...");
1663
- const lsResult = await runLintStaged();
1664
- if (lsResult.ok) {
1665
- lsSpinner.stop("All lint-staged checks passed");
1666
- if (lsResult.stdout.trim()) log.info(dim(lsResult.stdout.trim()));
1667
- } else {
1668
- lsSpinner.stop("Lint-staged checks failed");
1669
- log.info(lsResult.stderr?.trim() || lsResult.stdout?.trim() || "Unknown error");
1670
- }
1671
- changedFiles = await getChangedFiles();
1672
- continue;
1673
- }
1674
- if (!stagingResult) {
1675
- outro(dim("Cancelled."));
1676
- return;
1677
- }
1678
- filesToStage = stagingResult.files;
1679
- stageAllFlag = stagingResult.all;
1680
- break;
1681
- }
1682
- s.start(`Staging ${filesToStage.length} file${filesToStage.length !== 1 ? "s" : ""}...`);
1683
- if (stageAllFlag) await stageAll();
1684
- else await stageFiles(filesToStage);
1685
- s.stop("Files staged");
2080
+ const result = await handleStaging(changedFiles, flags);
2081
+ if (!result) return;
2082
+ changedFiles = result.changedFiles;
1686
2083
  }
1687
2084
  } catch (err) {
1688
2085
  s.stop(red("Staging failed."));
@@ -1691,6 +2088,8 @@ async function commitCommand(flags) {
1691
2088
  outro(red(`Failed to stage files: ${msg}`));
1692
2089
  process.exit(1);
1693
2090
  }
2091
+ changedFiles = await getChangedFiles();
2092
+ await runPreCommitChecks(changedFiles, flags.noCheck);
1694
2093
  const diffResult = await getStagedDiff();
1695
2094
  if (!diffResult) {
1696
2095
  debug("No staged changes found after staging");
@@ -1701,22 +2100,14 @@ async function commitCommand(flags) {
1701
2100
  debug("All staged files are excluded:", diffResult.excludedFiles);
1702
2101
  const message = buildExcludedFilesMessage(diffResult.excludedFiles);
1703
2102
  log.info(diffResult.excludedFiles.map((f) => ` ${f}`).join("\n"));
1704
- const { getRepoRoot } = await Promise.resolve().then(() => git_exports);
1705
2103
  await saveCachedCommit(await getRepoRoot(), message);
1706
2104
  s.start("Running pre-commit hooks...");
1707
- const headBefore = await getHead();
1708
- const result = await attemptCommit(message, [], createProgressHandler(s));
1709
- const headAfter = await getHead();
1710
- if (result.ok || headBefore !== headAfter) {
1711
- s.stop("Committed successfully.");
2105
+ const result = await commitWithRecovery(message, s, await getHead());
2106
+ if (result === "committed") {
1712
2107
  outro(green("Done."));
1713
2108
  return;
1714
2109
  }
1715
- s.stop("Commit failed.");
1716
- if (await showRecoveryMenu(parseHookErrors(result.stderr ?? ""), async () => (await attemptCommit(message)).ok, async (msg) => (await attemptCommitNoVerify(msg)).ok, async () => {
1717
- await stageAll();
1718
- return (await attemptCommit(message)).ok;
1719
- }, message, result.stderr ?? "") === "cancelled") process.exit(1);
2110
+ if (result === "cancelled") process.exit(1);
1720
2111
  return;
1721
2112
  }
1722
2113
  debug("Staged files:", diffResult.files);
@@ -1727,22 +2118,25 @@ async function commitCommand(flags) {
1727
2118
  debug("Using provided message:", flags.message);
1728
2119
  message = flags.message;
1729
2120
  } else {
2121
+ const config = await readConfig();
2122
+ const provider = isValidProvider(config.provider ?? "groq") ? config.provider : "groq";
1730
2123
  try {
1731
- await getApiKey();
2124
+ await getProviderApiKey(provider);
1732
2125
  debug("API key found");
1733
2126
  } catch {
1734
2127
  debug("No API key found, prompting user");
1735
2128
  const { text: promptText } = await import("@clack/prompts");
2129
+ const configKey = PROVIDER_ENV_KEYS[provider];
1736
2130
  const key = await promptText({
1737
- message: "Enter your Groq API key:",
1738
- placeholder: "gsk_...",
2131
+ message: `Enter your ${formatProviderName(provider)} API key:`,
2132
+ placeholder: provider === "groq" ? "gsk_..." : "...",
1739
2133
  validate: (v) => v?.trim() ? void 0 : "API key is required"
1740
2134
  });
1741
2135
  if (isCancel(key)) {
1742
2136
  outro(dim("Cancelled."));
1743
2137
  return;
1744
2138
  }
1745
- await setConfigValue("GROQ_API_KEY", String(key).trim());
2139
+ await setConfigValue(configKey, String(key).trim());
1746
2140
  debug("API key saved to config");
1747
2141
  }
1748
2142
  s.start("Generating commit message...");
@@ -1765,66 +2159,197 @@ async function commitCommand(flags) {
1765
2159
  return;
1766
2160
  }
1767
2161
  message = reviewed;
1768
- const { getRepoRoot } = await Promise.resolve().then(() => git_exports);
1769
2162
  const repoRoot = await getRepoRoot();
1770
2163
  await saveCachedCommit(repoRoot, message);
1771
2164
  debug("Message cached for repo:", repoRoot);
1772
2165
  s.start("Running pre-commit hooks...");
1773
2166
  const headBefore = await getHead();
1774
2167
  debug("HEAD before commit:", headBefore);
1775
- const result = await attemptCommit(message, [], createProgressHandler(s));
1776
- const headAfter = await getHead();
1777
- debug("HEAD after commit:", headAfter);
2168
+ const result = await commitWithRecovery(message, s, headBefore);
1778
2169
  debug("Commit result:", result);
1779
- if (result.ok || headBefore !== headAfter) {
1780
- s.stop("Committed successfully.");
1781
- const checks = parseToolChecks(result.stderr ?? "");
1782
- if (checks.length > 0) {
1783
- const lines = checks.map((c) => ` ${c.ok ? green("✓") : red("✗")} ${c.tool}`);
1784
- log.info(lines.join("\n"));
1785
- }
2170
+ if (result === "committed") {
1786
2171
  outro(green("Done."));
1787
2172
  return;
1788
2173
  }
1789
- s.stop("Commit failed.");
1790
- debug("Commit failed, showing recovery menu");
1791
- const errors = parseHookErrors(result.stderr ?? "");
1792
- debug("Parsed hook errors:", errors.length, "errors");
1793
- if (await showRecoveryMenu(errors, async () => {
1794
- return (await attemptCommit(message)).ok;
1795
- }, async (msg) => {
1796
- return (await attemptCommitNoVerify(msg)).ok;
1797
- }, async () => {
1798
- await stageAll();
1799
- return (await attemptCommit(message)).ok;
1800
- }, message, result.stderr ?? "") === "cancelled") process.exit(1);
2174
+ if (result === "cancelled") process.exit(1);
1801
2175
  }
1802
2176
  //#endregion
1803
2177
  //#region src/commands/config.ts
1804
- const configCommand = command({
1805
- name: "config",
1806
- parameters: ["<mode>", "<key=value...>"]
1807
- }, async (argv) => {
1808
- const { mode, keyValue } = argv._;
1809
- if (mode === "get") {
1810
- for (const kv of keyValue) {
1811
- const key = kv.split("=")[0];
1812
- const value = await getConfigValue(key);
1813
- console.log(`${key}=${value ?? ""}`);
1814
- }
1815
- 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."));
1816
2322
  }
1817
- if (mode === "set") {
1818
- for (const kv of keyValue) {
1819
- const [key, ...rest] = kv.split("=");
1820
- 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;
1821
2344
  }
1822
- console.log("Config updated.");
1823
- return;
2345
+ if (action === "done") {
2346
+ debug("configCommand: done");
2347
+ p.outro("Config saved.");
2348
+ return;
2349
+ }
2350
+ await editSettingsLoop(config);
1824
2351
  }
1825
- console.error(`Unknown config mode: ${mode}. Use "get" or "set".`);
1826
- process.exit(1);
1827
- });
2352
+ }
1828
2353
  //#endregion
1829
2354
  //#region src/cli.ts
1830
2355
  const { version } = package_default;
@@ -1866,9 +2391,17 @@ cli({
1866
2391
  description: "Enable debug output",
1867
2392
  alias: "d",
1868
2393
  default: false
2394
+ },
2395
+ noCheck: {
2396
+ type: Boolean,
2397
+ description: "Skip user-defined pre-commit checks",
2398
+ alias: "N",
2399
+ default: false
1869
2400
  }
1870
2401
  },
1871
- commands: [configCommand]
2402
+ commands: [command({ name: "config" }, async () => {
2403
+ await configCommand();
2404
+ })]
1872
2405
  }, (argv) => {
1873
2406
  setDebug(argv.flags.debug);
1874
2407
  if (argv.flags.review) reviewCommand();