@kyubiware/commit-mint 0.4.2 → 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.mjs CHANGED
@@ -1,16 +1,18 @@
1
1
  #!/usr/bin/env node
2
2
  import { cli, command } from "cleye";
3
3
  import * as p from "@clack/prompts";
4
- import { intro, isCancel, log, note, outro, select, spinner } from "@clack/prompts";
4
+ import { intro, isCancel, log, outro, spinner } from "@clack/prompts";
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 { execa } from "execa";
11
- import { spawn } from "node:child_process";
12
10
  import Groq from "groq-sdk";
11
+ import { execa } from "execa";
13
12
  import { createHash } from "node:crypto";
13
+ import { readFileSync } from "node:fs";
14
+ import picomatch from "picomatch";
15
+ import { spawn } from "node:child_process";
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.1",
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,195 +567,20 @@ 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
- //#region src/services/clipboard.ts
569
- async function copyToClipboard(content) {
570
- for (const [cmd, args] of [
571
- ["wl-copy", []],
572
- ["xclip", ["-selection", "clipboard"]],
573
- ["xsel", ["--clipboard", "--input"]],
574
- ["pbcopy", []]
575
- ]) try {
576
- if (await new Promise((resolve) => {
577
- const child = spawn(cmd, args, { stdio: [
578
- "pipe",
579
- "ignore",
580
- "ignore"
581
- ] });
582
- let settled = false;
583
- const done = (result) => {
584
- if (settled) return;
585
- settled = true;
586
- resolve(result);
587
- };
588
- child.on("error", () => done(false));
589
- child.on("exit", (code) => {
590
- if (code !== 0) done(false);
591
- });
592
- child.stdin.write(content, (err) => {
593
- if (err) {
594
- done(false);
595
- return;
596
- }
597
- child.stdin.end(() => {
598
- child.unref();
599
- done(true);
600
- });
601
- });
602
- })) return true;
603
- } catch {}
604
- return false;
605
- }
606
- //#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;
570
+ //#region src/ui/review-message.ts
571
+ async function reviewCommitMessage(message) {
572
+ const { select, text } = await import("@clack/prompts");
686
573
  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?",
574
+ const review = await select({
575
+ message: `Review commit message:\n\n ${bold(message)}\n`,
693
576
  options: [
694
577
  {
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"
578
+ label: "Use as-is",
579
+ value: "use"
708
580
  },
709
581
  {
710
- label: "Edit commit message",
711
- value: "edit",
712
- hint: "Modify the message before retrying"
582
+ label: "Edit",
583
+ value: "edit"
713
584
  },
714
585
  {
715
586
  label: "Cancel",
@@ -717,73 +588,74 @@ async function showRecoveryMenu(errors, onRetry, onSkipHooks, onRestage, message
717
588
  }
718
589
  ]
719
590
  });
720
- if (p.isCancel(choice)) {
721
- debug("showRecoveryMenu: user cancelled");
722
- p.outro(yellow("Cancelled. Message cached for --retry."));
723
- return "cancelled";
591
+ if (isCancel(review) || review === "cancel") {
592
+ debug("User cancelled at review step");
593
+ return null;
724
594
  }
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";
595
+ if (review === "use") {
596
+ debug("User accepted message");
597
+ return message;
598
+ }
599
+ if (review === "edit") {
600
+ debug("User chose to edit message");
601
+ const edited = await text({
602
+ message: "Edit commit message:",
603
+ initialValue: message,
604
+ validate: (v) => v?.trim() ? void 0 : "Message cannot be empty"
605
+ });
606
+ if (isCancel(edited)) continue;
607
+ message = String(edited).trim();
608
+ debug("Edited message:", message);
771
609
  }
772
610
  }
773
611
  }
774
612
  //#endregion
775
- //#region src/services/ai.ts
776
- 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.");
780
- 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}`);
782
- return /* @__PURE__ */ new Error(`Unexpected error: ${error instanceof Error ? error.message : String(error)}`);
783
- }
784
- const CONVENTIONAL_COMMIT_REGEX = /^(build|chore|ci|docs|feat|fix|perf|refactor|revert|style|test)(\(.+\))?!?: .+$/;
785
- function stripThinkTags(text) {
786
- return text.replace(/<think[\s\S]*?<\/think>/gi, "").trim();
613
+ //#region src/utils/cache.ts
614
+ const CACHE_DIR = join(os.homedir(), ".cache", "commit-mint");
615
+ function repoHash(repoPath) {
616
+ return createHash("sha256").update(repoPath).digest("hex").slice(0, 12);
617
+ }
618
+ function cachePath(repoPath) {
619
+ return join(CACHE_DIR, `${repoHash(repoPath)}.json`);
620
+ }
621
+ async function saveCachedCommit(repoPath, message) {
622
+ await mkdir(CACHE_DIR, { recursive: true });
623
+ const data = {
624
+ message,
625
+ timestamp: Date.now(),
626
+ repoPath
627
+ };
628
+ const path = cachePath(repoPath);
629
+ debug("saveCachedCommit: saving to %s", path);
630
+ await writeFile(path, JSON.stringify(data, null, 2), "utf8");
631
+ }
632
+ async function loadCachedCommit(repoPath) {
633
+ const path = cachePath(repoPath);
634
+ debug("loadCachedCommit: loading from %s", path);
635
+ try {
636
+ const raw = await readFile(path, "utf8");
637
+ const data = JSON.parse(raw);
638
+ debug("loadCachedCommit: found message from %s", new Date(data.timestamp).toISOString());
639
+ return data;
640
+ } catch {
641
+ debug("loadCachedCommit: no cached commit found");
642
+ return null;
643
+ }
644
+ }
645
+ //#endregion
646
+ //#region src/services/ai.ts
647
+ const MAX_DIFF_CHARS = 2e4;
648
+ function mapGroqError(error, providerLabel) {
649
+ const label = providerLabel ?? "Groq";
650
+ if (error instanceof Groq.AuthenticationError) return /* @__PURE__ */ new Error(`Invalid API key for ${label}. Run: cmint config set ${label.toUpperCase()}_API_KEY=<key>`);
651
+ if (error instanceof Groq.RateLimitError) return /* @__PURE__ */ new Error(`Rate limited by ${label}. Please wait and try again.`);
652
+ if (error instanceof Groq.APIConnectionTimeoutError) return /* @__PURE__ */ new Error("Request timed out. Check your network or try a smaller diff.");
653
+ if (error instanceof Groq.APIError) return /* @__PURE__ */ new Error(`${label} API error: ${error.message}`);
654
+ return /* @__PURE__ */ new Error(`Unexpected error: ${error instanceof Error ? error.message : String(error)}`);
655
+ }
656
+ const CONVENTIONAL_COMMIT_REGEX = /^(build|chore|ci|docs|feat|fix|perf|refactor|revert|style|test)(\(.+\))?!?: .+$/;
657
+ function stripThinkTags(text) {
658
+ return text.replace(/<think[\s\S]*?<\/think>/gi, "").trim();
787
659
  }
788
660
  function deriveMessageFromReasoning(reasoning) {
789
661
  const match = reasoning.match(/(build|chore|ci|docs|feat|fix|perf|refactor|revert|style|test)(\(.+\))?!?: .+/i);
@@ -863,13 +735,16 @@ function extractContentText(content) {
863
735
  return "";
864
736
  }
865
737
  async function generateCommitMessage(diff, options) {
866
- debug("generateCommitMessage: model=%s, type=%s, hint=%s", options.model ?? "default", options.type ?? "none", options.hint ?? "none");
867
738
  const timeoutMs = options.timeout ?? 6e4;
868
739
  debug("Timeout: %d ms", timeoutMs);
869
- const client = new Groq({
740
+ const { client, model } = createProvider({
741
+ provider: options.provider ?? "groq",
870
742
  apiKey: options.apiKey,
871
- timeout: timeoutMs
743
+ modelOverride: options.model,
744
+ timeout: timeoutMs,
745
+ baseURLOverride: options.proxy
872
746
  });
747
+ debug("generateCommitMessage: model=%s, type=%s, hint=%s", model, options.type ?? "none", options.hint ?? "none");
873
748
  const compressedDiff = compressDiff(diff);
874
749
  const statSummary = buildStatSummary(diff);
875
750
  const systemPrompt = buildSystemPrompt(options.type);
@@ -879,9 +754,9 @@ async function generateCommitMessage(diff, options) {
879
754
  debug("User prompt length: %d chars", userPrompt.length);
880
755
  async function callAI(strictSystemPrompt) {
881
756
  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);
757
+ debug("callAI: %s — model=%s, promptLen=%d, systemLen=%d", !!strictSystemPrompt ? "RETRY (strict)" : "INITIAL", model, userPrompt.length, (strictSystemPrompt ?? systemPrompt).length);
883
758
  try {
884
- const isReasoningModel = /^(o[1-9]|.*gpt-oss.*|.*gpt-5.*)/i.test(options.model ?? "");
759
+ const isReasoningModel = /^(o[1-9]|.*gpt-oss.*|.*gpt-5.*)/i.test(model);
885
760
  const completion = await client.chat.completions.create({
886
761
  messages: [{
887
762
  role: "system",
@@ -890,7 +765,7 @@ async function generateCommitMessage(diff, options) {
890
765
  role: "user",
891
766
  content: userPrompt
892
767
  }],
893
- model: options.model ?? "openai/gpt-oss-20b",
768
+ model,
894
769
  temperature: .3,
895
770
  ...isReasoningModel ? { max_completion_tokens: 1024 } : { max_tokens: 1024 },
896
771
  reasoning_format: "parsed"
@@ -936,332 +811,205 @@ async function generateCommitMessage(diff, options) {
936
811
  return message;
937
812
  } catch (error) {
938
813
  debug("AI error: %s", error instanceof Error ? error.message : String(error));
939
- throw mapGroqError$1(error);
940
- }
941
- }
942
- //#endregion
943
- //#region src/services/review-ai.ts
944
- function buildReviewSystemPrompt() {
945
- return [
946
- "You are an expert code reviewer. Review the following staged git diff.",
947
- "",
948
- "Focus on finding:",
949
- "1. **Bugs** — logic errors, off-by-one, race conditions, null pointer risks",
950
- "2. **Security issues** — injection, exposure of secrets, missing validation, CSRF, XSS",
951
- "3. **Performance problems** — unnecessary work, large allocations in hot paths",
952
- "4. **Code quality** — readability, maintainability, error handling gaps",
953
- "5. **Edge cases** — missing boundary checks, empty states, error states",
954
- "",
955
- "For each issue found, use this format:",
956
- "- SEVERITY: [critical|major|minor|suggestion]",
957
- "- LOCATION: <file-path>:<line-number>",
958
- "- ISSUE: <description>",
959
- "- FIX: <suggested resolution>",
960
- "",
961
- "Separate issues with a blank line.",
962
- "",
963
- "If you find NO issues at all, respond with exactly: NO_ISSUES_FOUND",
964
- "",
965
- "Be thorough but practical. Only flag real problems — not style preferences or nitpicks."
966
- ].join("\n");
967
- }
968
- function buildReviewPrompt(diff, files, statSummary) {
969
- const parts = [];
970
- parts.push(`Review the following staged changes (${files.length} files):`);
971
- parts.push("");
972
- parts.push(statSummary);
973
- parts.push("");
974
- parts.push("```diff");
975
- parts.push(diff);
976
- parts.push("```");
977
- return parts.join("\n");
978
- }
979
- async function generateCodeReview(diff, files, options) {
980
- debug("generateCodeReview: model=%s, files=%d", options.model ?? "default", files.length);
981
- const timeoutMs = options.timeout ?? 6e4;
982
- const client = new Groq({
983
- apiKey: options.apiKey,
984
- timeout: timeoutMs
985
- });
986
- const compressedDiff = compressDiff(diff);
987
- const statSummary = buildStatSummary(diff);
988
- const systemPrompt = buildReviewSystemPrompt();
989
- const userPrompt = buildReviewPrompt(compressedDiff, files, statSummary);
990
- debug("Code review: %d chars → %d chars, system=%d chars, user=%d chars", diff.length, compressedDiff.length, systemPrompt.length, userPrompt.length);
991
- try {
992
- const completion = await client.chat.completions.create({
993
- messages: [{
994
- role: "system",
995
- content: systemPrompt
996
- }, {
997
- role: "user",
998
- content: userPrompt
999
- }],
1000
- model: options.model ?? "openai/gpt-oss-20b",
1001
- temperature: .3,
1002
- max_tokens: 4096
1003
- });
1004
- const rawContent = completion.choices[0]?.message?.content;
1005
- const content = extractContentText(rawContent);
1006
- debug("generateCodeReview response: choices=%d, finishReason=%s, contentLen=%d", completion.choices.length, completion.choices[0]?.finish_reason ?? "(none)", content.length);
1007
- if (!content) {
1008
- const reasoning = completion.choices[0]?.message?.reasoning;
1009
- if (reasoning) {
1010
- const derived = deriveMessageFromReasoning(reasoning);
1011
- if (derived) {
1012
- debug("generateCodeReview: derived from reasoning");
1013
- return derived;
1014
- }
1015
- }
1016
- return "NO_ISSUES_FOUND";
1017
- }
1018
- return content;
1019
- } catch (error) {
1020
- debug("generateCodeReview error: %s", error instanceof Error ? error.message : String(error));
1021
- throw mapGroqError$1(error);
814
+ throw mapGroqError(error, options.provider ? formatProviderName(options.provider) : void 0);
1022
815
  }
1023
816
  }
1024
817
  //#endregion
1025
- //#region src/commands/review.ts
1026
- async function reviewCommand() {
1027
- debug("reviewCommand called");
1028
- await assertGitRepo();
1029
- const s = spinner();
1030
- s.start("Staging all changes...");
1031
- await stageAll();
1032
- s.stop("Changes staged");
1033
- const diffResult = await getStagedDiff();
1034
- if (!diffResult) {
1035
- outro(dim("No changes to review."));
1036
- return;
1037
- }
1038
- if ("excludedFiles" in diffResult) {
1039
- outro(dim("Staged files are all excluded from review."));
1040
- return;
1041
- }
1042
- intro("commit-mint — code review");
1043
- log.info(diffResult.files.map((f) => ` ${f}`).join("\n"));
1044
- const report = await isOpenCodeAvailable() ? await reviewWithOpenCode(diffResult.diff, diffResult.files) : await reviewWithGroq(diffResult.diff, diffResult.files);
1045
- if (report !== "NO_ISSUES_FOUND" && report.trim().length > 0) {
1046
- note(report, red(bold("Review findings")));
1047
- await offerClipboardCopy(report);
1048
- } else outro(green("No issues found. Looks good!"));
1049
- }
1050
- async function offerClipboardCopy(report) {
1051
- const shouldCopy = await select({
1052
- message: "Copy review report to clipboard?",
1053
- options: [{
1054
- label: "Yes, copy to clipboard",
1055
- value: "yes"
1056
- }, {
1057
- label: "No",
1058
- value: "no"
1059
- }]
1060
- });
1061
- if (isCancel(shouldCopy) || shouldCopy === "no") {
1062
- outro(dim("Done."));
1063
- return;
1064
- }
1065
- if (await copyToClipboard(report)) outro(green("Report copied to clipboard. You can paste it anywhere for fixes."));
1066
- else outro(red("Failed to copy to clipboard. Install xclip, wl-copy, or xsel."));
818
+ //#region src/services/checks.ts
819
+ /** Config file names, checked in priority order (matches lint-staged naming conventions) */
820
+ const CONFIG_FILES = [
821
+ ".cmintrc",
822
+ ".cmintrc.json",
823
+ ".cmintrc.mjs",
824
+ ".cmintrc.mts",
825
+ ".cmintrc.js",
826
+ ".cmintrc.ts",
827
+ ".cmintrc.cjs",
828
+ ".cmintrc.cts",
829
+ "cmint.config.mjs",
830
+ "cmint.config.mts",
831
+ "cmint.config.js",
832
+ "cmint.config.ts",
833
+ "cmint.config.cjs",
834
+ "cmint.config.cts"
835
+ ];
836
+ /**
837
+ * Detect whether the repo has a cmint config file.
838
+ * Returns the config file path, or null if none found.
839
+ */
840
+ async function detectConfig(repoRoot) {
841
+ debug("detectConfig: checking for config in %s", repoRoot);
842
+ for (const name of CONFIG_FILES) try {
843
+ await access(join(repoRoot, name), constants.R_OK);
844
+ debug("detectConfig: found %s", name);
845
+ return join(repoRoot, name);
846
+ } catch {}
847
+ debug("detectConfig: no config file found");
848
+ return null;
1067
849
  }
1068
- async function isOpenCodeAvailable() {
1069
- try {
1070
- const { exitCode } = await import("execa").then((m) => m.execa("which", ["opencode"], { reject: false }));
1071
- return exitCode === 0;
1072
- } catch {
1073
- return false;
1074
- }
850
+ /**
851
+ * Load and validate the cmint config from a repo root.
852
+ * Throws if the loaded value is missing or not a non-null object.
853
+ */
854
+ async function loadConfig(repoRoot) {
855
+ const configPath = await detectConfig(repoRoot);
856
+ if (!configPath) throw new Error("No cmint config file found");
857
+ debug("loadConfig: loading %s", configPath);
858
+ const ext = extname(configPath);
859
+ const isJSON = ext === ".json";
860
+ const needsJiti = ext === ".ts" || ext === ".mts" || ext === ".cts" || ext === ".cjs";
861
+ let config;
862
+ if (isJSON) {
863
+ const raw = readFileSync(configPath, "utf-8");
864
+ config = JSON.parse(raw);
865
+ } else if (needsJiti) {
866
+ const { createJiti } = await import("jiti");
867
+ const mod = await createJiti(import.meta.url, {}).import(configPath);
868
+ config = mod.default ?? mod;
869
+ } else config = (await import(configPath)).default;
870
+ if (!config || typeof config !== "object" || Array.isArray(config)) throw new Error("cmint config must export a non-null object with glob→command mappings");
871
+ debug("loadConfig: loaded %d glob patterns", Object.keys(config).length);
872
+ return config;
1075
873
  }
1076
- async function reviewWithGroq(diff, files) {
1077
- const s = spinner();
1078
- s.start("Reviewing with Groq...");
874
+ /**
875
+ * Run a shell command and capture its output.
876
+ * Returns a CheckResult with ok=true on success (exit 0), ok=false on failure.
877
+ * Handles ENOENT (command not found) and timeout errors gracefully.
878
+ */
879
+ async function runCommand(command, timeout, repoRoot) {
880
+ debug("runCommand: %s (timeout: %dms)", command, timeout);
881
+ const tool = extractToolName(command) ?? command.split(" ")[0];
1079
882
  try {
1080
- const config = await readConfig();
1081
- 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
883
+ const result = await execa(command, {
884
+ shell: true,
885
+ reject: false,
886
+ timeout,
887
+ all: true,
888
+ preferLocal: true,
889
+ ...repoRoot ? { localDir: repoRoot } : {}
1085
890
  });
1086
- s.stop("Review complete");
1087
- return report;
891
+ const ok = !result.failed;
892
+ debug("runCommand: %s — ok=%s", tool, ok);
893
+ return {
894
+ ok,
895
+ tool,
896
+ command,
897
+ stdout: result.stdout ?? "",
898
+ stderr: result.stderr ?? "",
899
+ files: []
900
+ };
1088
901
  } catch (err) {
1089
- s.stop(red("Review failed."));
1090
- debug("reviewWithGroq error:", err instanceof Error ? err.message : String(err));
1091
- throw err;
902
+ const msg = err instanceof Error ? err.message : String(err);
903
+ const isTimedOut = msg.toLowerCase().includes("timed out");
904
+ const isNotFound = msg.toLowerCase().includes("enoent") || msg.toLowerCase().includes("not found");
905
+ debug("runCommand: %s — error: %s", tool, msg);
906
+ return {
907
+ ok: false,
908
+ tool,
909
+ command,
910
+ stdout: "",
911
+ stderr: isTimedOut ? `Check timed out after ${timeout}ms` : isNotFound ? `Command not found: ${tool}` : msg,
912
+ files: []
913
+ };
1092
914
  }
1093
915
  }
1094
- async function reviewWithOpenCode(diff, files) {
1095
- const s = spinner();
1096
- s.start("Running OpenCode review...");
1097
- try {
1098
- const repoRoot = await getRepoRoot();
1099
- const prompt = [
1100
- "Review the staged changes in this git repository.",
1101
- "Analyze the code diff for bugs, security issues, performance problems,",
1102
- "code quality issues, and missing edge cases.",
1103
- "",
1104
- `Files changed (${files.length}):`,
1105
- ...files.map((f) => ` - ${f}`),
1106
- "",
1107
- "```diff",
1108
- diff.slice(0, 15e3),
1109
- "```",
1110
- "",
1111
- "Provide a structured report with severity, location, issue, and fix suggestion for each finding.",
1112
- "If no issues found, respond with: NO_ISSUES_FOUND"
1113
- ].join("\n");
1114
- const { stdout } = await import("execa").then((m) => m.execa("opencode", [
1115
- "run",
1116
- prompt,
1117
- "--dir",
1118
- repoRoot
1119
- ], {
1120
- timeout: 12e4,
1121
- reject: false
1122
- }));
1123
- s.stop("Review complete");
1124
- return stdout || "OpenCode review completed but no output captured.";
1125
- } catch (err) {
1126
- s.stop(red("OpenCode review failed."));
1127
- debug("reviewWithOpenCode error:", err instanceof Error ? err.message : String(err));
1128
- throw new Error(`OpenCode review failed: ${err instanceof Error ? err.message : String(err)}`);
1129
- }
916
+ /**
917
+ * Filter a list of file paths by a picomatch glob pattern.
918
+ * When the pattern contains no `/`, files are matched at any depth (matchBase).
919
+ * Dotfiles are included (dot: true).
920
+ */
921
+ function matchFiles(pattern, files) {
922
+ if (!pattern) return [];
923
+ const matchBase = !pattern.includes("/");
924
+ const isMatch = picomatch(pattern, {
925
+ dot: true,
926
+ posixSlashes: true,
927
+ strictBrackets: true
928
+ });
929
+ return files.filter((f) => {
930
+ const parts = f.split("/");
931
+ return isMatch(matchBase ? parts[parts.length - 1] : f);
932
+ });
1130
933
  }
1131
- //#endregion
1132
- //#region src/ui/review-message.ts
1133
- async function runCodeReview() {
1134
- const diffResult = await getStagedDiff();
1135
- if (!diffResult || "excludedFiles" in diffResult) {
1136
- outro(dim("No staged changes to review."));
1137
- return;
1138
- }
1139
- const opencodeAvailable = await isOpenCodeAvailable();
1140
- const s = spinner();
1141
- s.start(opencodeAvailable ? "Running OpenCode review..." : "Running Groq review...");
1142
- try {
1143
- const report = opencodeAvailable ? await reviewWithOpenCode(diffResult.diff, diffResult.files) : await reviewWithGroq(diffResult.diff, diffResult.files);
1144
- s.stop("Review complete");
1145
- await showReviewResults(report);
1146
- } catch (err) {
1147
- s.stop(red("Review failed."));
1148
- debug("Code review error:", err instanceof Error ? err.message : String(err));
1149
- outro(red(err instanceof Error ? err.message : String(err)));
1150
- }
934
+ /**
935
+ * Build a shell command string from a base command and a list of file paths.
936
+ * File paths containing spaces are wrapped in double quotes.
937
+ * If no files are provided, the base command is returned as-is.
938
+ */
939
+ function buildCommand(command, files) {
940
+ if (files.length === 0) return command;
941
+ return `${command} ${files.map((f) => f.includes(" ") ? `"${f}"` : f).join(" ")}`;
1151
942
  }
1152
- async function showReviewResults(report) {
1153
- const { note: clackNote, select: clackSelect } = await import("@clack/prompts");
1154
- if (!(report !== "NO_ISSUES_FOUND" && report.trim().length > 0)) {
1155
- log.info(green("No issues found."));
1156
- return;
943
+ /**
944
+ * Resolve config commands for a glob entry into an array of command strings.
945
+ * Function commands receive matched filenames; string commands are used as-is.
946
+ */
947
+ function resolveCommands(commands, matchedFiles) {
948
+ if (typeof commands === "function") {
949
+ const resolved = commands(matchedFiles);
950
+ return Array.isArray(resolved) ? resolved : [resolved];
1157
951
  }
1158
- clackNote(report, red(bold("Review findings")));
1159
- const shouldCopy = await clackSelect({
1160
- message: "Copy review report to clipboard?",
1161
- options: [{
1162
- label: "Yes, copy to clipboard",
1163
- value: "yes"
1164
- }, {
1165
- label: "No",
1166
- value: "no"
1167
- }]
1168
- });
1169
- if (isCancel(shouldCopy) || shouldCopy !== "yes") return;
1170
- if (await copyToClipboard(report)) log.info(green("Report copied to clipboard."));
1171
- else log.warn(red("Failed to copy to clipboard."));
952
+ return Array.isArray(commands) ? commands : [commands];
1172
953
  }
1173
- async function reviewCommitMessage(message) {
1174
- const { select, text } = await import("@clack/prompts");
1175
- while (true) {
1176
- const review = await select({
1177
- message: `Review commit message:\n\n ${bold(message)}\n`,
1178
- options: [
1179
- {
1180
- label: "Use as-is",
1181
- value: "use"
1182
- },
1183
- {
1184
- label: "Edit",
1185
- value: "edit"
1186
- },
1187
- {
1188
- label: "Review with OpenCode",
1189
- value: "review"
1190
- },
1191
- {
1192
- label: "Cancel",
1193
- value: "cancel"
1194
- }
1195
- ]
954
+ /**
955
+ * Run resolved commands for a single glob entry, appending results.
956
+ * Returns false if any command fails (for fail-fast signaling).
957
+ */
958
+ async function runCommandsForGlob(cmds, isFunction, matchedFiles, timeout, results, repoRoot) {
959
+ for (const cmd of cmds) {
960
+ const fullCommand = isFunction ? cmd : buildCommand(cmd, matchedFiles);
961
+ debug("runCommandsForGlob: running '%s'", fullCommand);
962
+ const result = await runCommand(fullCommand, timeout, repoRoot);
963
+ results.push({
964
+ ...result,
965
+ files: matchedFiles
1196
966
  });
1197
- if (isCancel(review) || review === "cancel") {
1198
- debug("User cancelled at review step");
1199
- return null;
1200
- }
1201
- if (review === "use") {
1202
- debug("User accepted message");
1203
- return message;
967
+ if (!result.ok) {
968
+ debug("runCommandsForGlob: check failed, stopping (fail-fast)");
969
+ return false;
1204
970
  }
1205
- if (review === "edit") {
1206
- debug("User chose to edit message");
1207
- const edited = await text({
1208
- message: "Edit commit message:",
1209
- initialValue: message,
1210
- validate: (v) => v?.trim() ? void 0 : "Message cannot be empty"
1211
- });
1212
- if (isCancel(edited)) continue;
1213
- message = String(edited).trim();
1214
- debug("Edited message:", message);
971
+ }
972
+ return true;
973
+ }
974
+ /**
975
+ * Run all user-defined checks from .cmintrc against staged files.
976
+ * Returns a no-op result when no config exists.
977
+ * Fail-fast: stops on first error.
978
+ */
979
+ async function runAllChecks(repoRoot, stagedFiles, timeout) {
980
+ debug("runAllChecks: %d staged files, checking for config in %s", stagedFiles.length, repoRoot);
981
+ if (!await detectConfig(repoRoot)) {
982
+ debug("runAllChecks: no config found, skipping checks");
983
+ return {
984
+ ok: true,
985
+ results: []
986
+ };
987
+ }
988
+ const config = await loadConfig(repoRoot);
989
+ debug("runAllChecks: loaded config with %d patterns", Object.keys(config).length);
990
+ const results = [];
991
+ for (const [glob, commands] of Object.entries(config)) {
992
+ const matchedFiles = matchFiles(glob, stagedFiles);
993
+ const isFunction = typeof commands === "function";
994
+ if (matchedFiles.length === 0) {
995
+ debug("runAllChecks: no files matched pattern '%s'", glob);
1215
996
  continue;
1216
997
  }
1217
- if (review === "review") {
1218
- debug("User chose to review code");
1219
- await runCodeReview();
1220
- }
998
+ debug("runAllChecks: pattern '%s' matched %d files", glob, matchedFiles.length);
999
+ if (!await runCommandsForGlob(resolveCommands(commands, matchedFiles), isFunction, matchedFiles, timeout, results, repoRoot)) return {
1000
+ ok: false,
1001
+ results
1002
+ };
1221
1003
  }
1222
- }
1223
- //#endregion
1224
- //#region src/utils/cache.ts
1225
- const CACHE_DIR = join(os.homedir(), ".cache", "commit-mint");
1226
- function repoHash(repoPath) {
1227
- return createHash("sha256").update(repoPath).digest("hex").slice(0, 12);
1228
- }
1229
- function cachePath(repoPath) {
1230
- return join(CACHE_DIR, `${repoHash(repoPath)}.json`);
1231
- }
1232
- async function saveCachedCommit(repoPath, message) {
1233
- await mkdir(CACHE_DIR, { recursive: true });
1234
- const data = {
1235
- message,
1236
- timestamp: Date.now(),
1237
- repoPath
1004
+ const ok = results.every((r) => r.ok);
1005
+ debug("runAllChecks: complete — ok=%s, %d results", ok, results.length);
1006
+ return {
1007
+ ok,
1008
+ results
1238
1009
  };
1239
- const path = cachePath(repoPath);
1240
- debug("saveCachedCommit: saving to %s", path);
1241
- await writeFile(path, JSON.stringify(data, null, 2), "utf8");
1242
- }
1243
- async function loadCachedCommit(repoPath) {
1244
- const path = cachePath(repoPath);
1245
- debug("loadCachedCommit: loading from %s", path);
1246
- try {
1247
- const raw = await readFile(path, "utf8");
1248
- const data = JSON.parse(raw);
1249
- debug("loadCachedCommit: found message from %s", new Date(data.timestamp).toISOString());
1250
- return data;
1251
- } catch {
1252
- debug("loadCachedCommit: no cached commit found");
1253
- return null;
1254
- }
1255
1010
  }
1256
1011
  //#endregion
1257
1012
  //#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)}`);
1264
- }
1265
1013
  function matchesExcludePattern(filePath, pattern) {
1266
1014
  if (pattern === filePath) return true;
1267
1015
  if (pattern.endsWith("/**")) {
@@ -1278,7 +1026,9 @@ function matchesExcludePattern(filePath, pattern) {
1278
1026
  const LOCKFILE_COMPANIONS = {
1279
1027
  "package-lock.json": "package.json",
1280
1028
  "pnpm-lock.yaml": "package.json",
1281
- "yarn.lock": "package.json"
1029
+ "yarn.lock": "package.json",
1030
+ "bun.lock": "package.json",
1031
+ "bun.lockb": "package.json"
1282
1032
  };
1283
1033
  function filterExcludedFiles(files) {
1284
1034
  const patterns = getDefaultExcludes();
@@ -1352,7 +1102,7 @@ function parseGroupingResponse(content) {
1352
1102
  });
1353
1103
  return rawGroups;
1354
1104
  }
1355
- async function generateGroups(files, apiKey, model, timeout) {
1105
+ async function generateGroups(files, apiKey, model, timeout, provider, proxy) {
1356
1106
  debug("generateGroups: %d files, model=%s", files.length, model ?? "default");
1357
1107
  const { included, excluded } = filterExcludedFiles(files);
1358
1108
  if (included.length === 0) {
@@ -1367,9 +1117,12 @@ async function generateGroups(files, apiKey, model, timeout) {
1367
1117
  const userPrompt = buildGroupingUserPrompt(summary);
1368
1118
  debug("File summary:\n%s", summary);
1369
1119
  debug("User prompt length: %d chars", userPrompt.length);
1370
- const client = new Groq({
1120
+ const { client, model: resolvedModel } = createProvider({
1121
+ provider: provider ?? "groq",
1371
1122
  apiKey,
1372
- timeout: timeout ?? 6e4
1123
+ modelOverride: model,
1124
+ timeout: timeout ?? 6e4,
1125
+ baseURLOverride: proxy
1373
1126
  });
1374
1127
  try {
1375
1128
  const completion = await client.chat.completions.create({
@@ -1380,7 +1133,7 @@ async function generateGroups(files, apiKey, model, timeout) {
1380
1133
  role: "user",
1381
1134
  content: userPrompt
1382
1135
  }],
1383
- model: model ?? "openai/gpt-oss-20b",
1136
+ model: resolvedModel,
1384
1137
  temperature: .3,
1385
1138
  max_tokens: 2048
1386
1139
  });
@@ -1399,7 +1152,7 @@ async function generateGroups(files, apiKey, model, timeout) {
1399
1152
  };
1400
1153
  } catch (error) {
1401
1154
  debug("generateGroups error: %s", error instanceof Error ? error.message : String(error));
1402
- throw mapGroqError(error);
1155
+ throw mapGroqError(error, provider ? formatProviderName(provider) : void 0);
1403
1156
  }
1404
1157
  }
1405
1158
  function validateGroups(groups, allFiles) {
@@ -1465,36 +1218,372 @@ async function showGroupingConfirmation(groups, excluded) {
1465
1218
  function showGroupProgress(current, total, groupName) {
1466
1219
  p.log.info(`Commit group ${current} of ${total}: ${cyan(`"${groupName}"`)}`);
1467
1220
  }
1468
- //#endregion
1469
- //#region src/commands/auto-group.ts
1470
- async function runAutoGroupFlow(changedFiles, flags) {
1471
- const { included, excluded } = filterExcludedFiles(changedFiles);
1472
- try {
1473
- await getApiKey();
1474
- debug("API key found");
1475
- } catch {
1476
- debug("No API key found, prompting user");
1477
- const { text: promptText } = await import("@clack/prompts");
1478
- const key = await promptText({
1479
- message: "Enter your Groq API key:",
1480
- placeholder: "gsk_...",
1481
- validate: (v) => v?.trim() ? void 0 : "API key is required"
1482
- });
1483
- if (isCancel(key)) {
1484
- outro(dim("Cancelled."));
1485
- return "cancelled";
1221
+ const statusLabel = (status) => {
1222
+ switch (status) {
1223
+ case "M": return yellow("M");
1224
+ case "A": return green("A");
1225
+ case "D": return red("D");
1226
+ case "?":
1227
+ case "??": return cyan("?");
1228
+ default: return dim(status);
1229
+ }
1230
+ };
1231
+ /** Display combined view: files with status indicators grouped by commit group */
1232
+ function showGroupedFiles(groups, changedFiles) {
1233
+ const statusMap = new Map(changedFiles.map((f) => [f.path, f.status]));
1234
+ const lines = [];
1235
+ for (let i = 0; i < groups.length; i++) {
1236
+ const group = groups[i];
1237
+ lines.push(`${bold(group.name)} ${dim("—")} ${group.files.length} file${group.files.length !== 1 ? "s" : ""}`);
1238
+ for (const file of group.files) {
1239
+ const status = statusMap.get(file) ?? "M";
1240
+ lines.push(` ${statusLabel(status)} ${file}`);
1486
1241
  }
1487
- await setConfigValue("GROQ_API_KEY", String(key).trim());
1488
- debug("API key saved to config");
1242
+ if (i < groups.length - 1) lines.push("");
1489
1243
  }
1490
- const s = spinner();
1491
- 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);
1494
- s.stop("Files analyzed");
1495
- if (flags.auto) debug("Auto mode: skipping grouping confirmation");
1496
- else if (!await showGroupingConfirmation(validatedGroups, excluded)) {
1497
- outro(dim("Cancelled."));
1244
+ p.note(lines.join("\n"), "Commit groups");
1245
+ }
1246
+ //#endregion
1247
+ //#region src/services/clipboard.ts
1248
+ async function copyToClipboard(content) {
1249
+ for (const [cmd, args] of [
1250
+ ["wl-copy", []],
1251
+ ["xclip", ["-selection", "clipboard"]],
1252
+ ["xsel", ["--clipboard", "--input"]],
1253
+ ["pbcopy", []]
1254
+ ]) try {
1255
+ if (await new Promise((resolve) => {
1256
+ const child = spawn(cmd, args, { stdio: [
1257
+ "pipe",
1258
+ "ignore",
1259
+ "ignore"
1260
+ ] });
1261
+ let settled = false;
1262
+ const done = (result) => {
1263
+ if (settled) return;
1264
+ settled = true;
1265
+ resolve(result);
1266
+ };
1267
+ child.on("error", () => done(false));
1268
+ child.on("exit", (code) => {
1269
+ if (code !== 0) done(false);
1270
+ });
1271
+ child.stdin.write(content, (err) => {
1272
+ if (err) {
1273
+ done(false);
1274
+ return;
1275
+ }
1276
+ child.stdin.end(() => {
1277
+ child.unref();
1278
+ done(true);
1279
+ });
1280
+ });
1281
+ })) return true;
1282
+ } catch {}
1283
+ return false;
1284
+ }
1285
+ //#endregion
1286
+ //#region src/ui/menu.ts
1287
+ async function showStagingMenu(files, hasChecks) {
1288
+ debug("showStagingMenu: %d files", files.length);
1289
+ const statusLabel = (status) => {
1290
+ switch (status) {
1291
+ case "M": return yellow("M");
1292
+ case "A": return green("A");
1293
+ case "D": return red("D");
1294
+ case "?":
1295
+ case "??": return cyan("?");
1296
+ default: return dim(status);
1297
+ }
1298
+ };
1299
+ const sorted = [...files].sort((a, b) => {
1300
+ if (a.staged !== b.staged) return a.staged ? -1 : 1;
1301
+ return a.path.localeCompare(b.path);
1302
+ });
1303
+ const stagedFiles = sorted.filter((f) => f.staged);
1304
+ const unstagedFiles = sorted.filter((f) => !f.staged);
1305
+ const lines = [];
1306
+ if (stagedFiles.length > 0) lines.push(green(bold("Staged:")), ...stagedFiles.map((f) => ` ${statusLabel(f.status)} ${f.path}`));
1307
+ if (unstagedFiles.length > 0) {
1308
+ if (lines.length > 0) lines.push("");
1309
+ lines.push(yellow(bold("Changed:")), ...unstagedFiles.map((f) => ` ${statusLabel(f.status)} ${f.path}`));
1310
+ }
1311
+ p.note(lines.join("\n"), `${files.length} file${files.length !== 1 ? "s" : ""}`);
1312
+ const choice = await p.select({
1313
+ message: "Stage files for commit:",
1314
+ options: [
1315
+ {
1316
+ label: "Auto-group into commits",
1317
+ value: "autogroup",
1318
+ hint: "LLM groups files into logical commits"
1319
+ },
1320
+ ...stagedFiles.length > 0 ? [{
1321
+ label: "Commit staged files only",
1322
+ value: "staged",
1323
+ hint: `${stagedFiles.length} file${stagedFiles.length !== 1 ? "s" : ""} already staged`
1324
+ }] : [],
1325
+ {
1326
+ label: "Stage all files",
1327
+ value: "all",
1328
+ hint: `${files.length} file${files.length !== 1 ? "s" : ""}`
1329
+ },
1330
+ ...hasChecks ? [{
1331
+ label: "Run checks",
1332
+ value: "checks",
1333
+ hint: "Pre-flight checks from cmint config"
1334
+ }] : [],
1335
+ {
1336
+ label: "Select files...",
1337
+ value: "select"
1338
+ },
1339
+ {
1340
+ label: "Cancel",
1341
+ value: "cancel"
1342
+ }
1343
+ ]
1344
+ });
1345
+ if (p.isCancel(choice) || choice === "cancel") return null;
1346
+ if (choice === "autogroup") return "autogroup";
1347
+ if (choice === "checks") return "checks";
1348
+ if (choice === "staged") return "staged";
1349
+ if (choice === "all") return {
1350
+ files: files.map((f) => f.path),
1351
+ all: true
1352
+ };
1353
+ const selected = await p.multiselect({
1354
+ message: "Select files to stage:",
1355
+ options: sorted.map((f) => ({
1356
+ label: `${statusLabel(f.status)} ${f.path}`,
1357
+ value: f.path
1358
+ })),
1359
+ required: true
1360
+ });
1361
+ if (p.isCancel(selected)) return null;
1362
+ return {
1363
+ files: selected,
1364
+ all: false
1365
+ };
1366
+ }
1367
+ async function showRecoveryMenu(errors, onRetry, onSkipHooks, onRestage, message, rawStderr) {
1368
+ debug("showRecoveryMenu: %d errors", errors.length);
1369
+ let clipboardCopied = false;
1370
+ let showNote = true;
1371
+ while (true) {
1372
+ if (showNote) {
1373
+ p.note(errors.map((e) => ` ${red("•")} [${e.tool}] ${e.message}`).join("\n"), red(bold("Pre-commit hook failed")));
1374
+ showNote = false;
1375
+ }
1376
+ const choice = await p.select({
1377
+ message: "What do you want to do?",
1378
+ options: [
1379
+ {
1380
+ label: clipboardCopied ? `${green("✓")} Copy error report to clipboard` : "Copy error report to clipboard",
1381
+ value: "clipboard",
1382
+ hint: clipboardCopied ? "Copied!" : "Paste into another terminal for an AI agent"
1383
+ },
1384
+ {
1385
+ label: "View full error output",
1386
+ value: "view",
1387
+ hint: "Show the raw stderr from hooks"
1388
+ },
1389
+ {
1390
+ label: "Skip hooks and commit (--no-verify)",
1391
+ value: "skip",
1392
+ hint: "Commit anyway, fix later"
1393
+ },
1394
+ {
1395
+ label: "Re-stage files and retry",
1396
+ value: "restage",
1397
+ hint: "Pick up fixes from another terminal"
1398
+ },
1399
+ {
1400
+ label: "Edit commit message",
1401
+ value: "edit",
1402
+ hint: "Modify the message before retrying"
1403
+ },
1404
+ {
1405
+ label: "Cancel",
1406
+ value: "cancel"
1407
+ }
1408
+ ]
1409
+ });
1410
+ if (p.isCancel(choice)) {
1411
+ debug("showRecoveryMenu: user cancelled");
1412
+ p.outro(yellow("Cancelled. Message cached for --retry."));
1413
+ return "cancelled";
1414
+ }
1415
+ debug("showRecoveryMenu: user chose %s", choice);
1416
+ switch (choice) {
1417
+ case "clipboard":
1418
+ if (await copyToClipboard(rawStderr)) {
1419
+ clipboardCopied = true;
1420
+ p.log.step(green("Copied to clipboard."));
1421
+ } else p.log.warn(red("No clipboard tool found. Install xclip, wl-copy, or xsel."));
1422
+ continue;
1423
+ case "view":
1424
+ p.note(rawStderr.trim() || "(no raw output)", "Full error output");
1425
+ showNote = true;
1426
+ continue;
1427
+ case "skip":
1428
+ p.log.info(yellow("Committing with --no-verify..."));
1429
+ if (await onSkipHooks(message)) {
1430
+ p.outro(green("Committed (hooks skipped)."));
1431
+ return "committed";
1432
+ } else {
1433
+ p.outro(red("Commit failed even with --no-verify."));
1434
+ return "failed";
1435
+ }
1436
+ case "restage":
1437
+ p.log.info(cyan("Re-staging and retrying..."));
1438
+ if (await onRestage()) {
1439
+ p.outro(green("Committed successfully."));
1440
+ return "committed";
1441
+ }
1442
+ showNote = true;
1443
+ continue;
1444
+ case "edit": {
1445
+ const edited = await p.text({
1446
+ message: "Edit commit message:",
1447
+ initialValue: message,
1448
+ validate: (v) => v?.trim() ? void 0 : "Message cannot be empty"
1449
+ });
1450
+ if (p.isCancel(edited)) {
1451
+ p.outro(yellow("Cancelled. Message cached for --retry."));
1452
+ return "cancelled";
1453
+ }
1454
+ if (await onRetry()) {
1455
+ p.outro(green("Committed successfully."));
1456
+ return "committed";
1457
+ } else {
1458
+ p.outro(red("Commit failed again."));
1459
+ return "failed";
1460
+ }
1461
+ }
1462
+ case "cancel":
1463
+ p.outro(dim("Message cached for --retry."));
1464
+ return "cancelled";
1465
+ }
1466
+ }
1467
+ }
1468
+ async function showCheckFailureMenu(errors, rawStderr) {
1469
+ debug("showCheckFailureMenu: %d errors", errors.length);
1470
+ let clipboardCopied = false;
1471
+ p.note(errors.map((e) => ` ${red("•")} [${e.tool}] ${e.message}`).join("\n"), red("Pre-commit check failed"));
1472
+ while (true) {
1473
+ const choice = await p.select({
1474
+ message: "What do you want to do?",
1475
+ options: [
1476
+ {
1477
+ label: clipboardCopied ? `${green("✓")} Copy error report to clipboard` : "Copy error report to clipboard",
1478
+ value: "copy"
1479
+ },
1480
+ {
1481
+ label: "View full error output",
1482
+ value: "view",
1483
+ hint: "Show the raw stderr from checks"
1484
+ },
1485
+ {
1486
+ label: "Skip checks and commit",
1487
+ value: "skip"
1488
+ },
1489
+ {
1490
+ label: "Cancel",
1491
+ value: "cancel"
1492
+ }
1493
+ ]
1494
+ });
1495
+ if (p.isCancel(choice)) {
1496
+ debug("showCheckFailureMenu: user cancelled");
1497
+ return "cancelled";
1498
+ }
1499
+ debug("showCheckFailureMenu: user chose %s", choice);
1500
+ switch (choice) {
1501
+ case "copy":
1502
+ if (await copyToClipboard(rawStderr)) {
1503
+ clipboardCopied = true;
1504
+ p.log.step(green("Copied to clipboard."));
1505
+ } else p.log.warn(red("No clipboard tool found. Install xclip, wl-copy, or xsel."));
1506
+ continue;
1507
+ case "view":
1508
+ p.note(rawStderr.trim() || "(no raw output)", "Full error output");
1509
+ continue;
1510
+ case "skip":
1511
+ p.log.info("Skipping checks and proceeding with commit...");
1512
+ return "skipped";
1513
+ case "cancel":
1514
+ p.outro(dim("Cancelled."));
1515
+ return "cancelled";
1516
+ }
1517
+ }
1518
+ }
1519
+ //#endregion
1520
+ //#region src/commands/auto-group.ts
1521
+ async function runAutoGroupFlow(changedFiles, flags) {
1522
+ const { included, excluded } = filterExcludedFiles(changedFiles);
1523
+ if (excluded.length > 0) {
1524
+ debug("Committing %d excluded files upfront:", excluded.length, excluded);
1525
+ const message = buildExcludedFilesMessage(excluded);
1526
+ await resetStaging();
1527
+ await stageFiles(excluded);
1528
+ const headBefore = await getHead();
1529
+ const commitResult = await attemptCommit(message);
1530
+ const headAfter = await getHead();
1531
+ if (commitResult.ok || headBefore !== headAfter) debug("Excluded files committed:", message);
1532
+ else debug("Excluded files commit failed, continuing without them");
1533
+ }
1534
+ if (included.length === 0) {
1535
+ debug("No included files to group, done");
1536
+ outro(green("Done."));
1537
+ return "committed";
1538
+ }
1539
+ if (!flags.noCheck) {
1540
+ const { getRepoRoot } = await Promise.resolve().then(() => git_exports);
1541
+ const repoRoot = await getRepoRoot();
1542
+ const allFiles = included.filter((f) => f.status !== "D").map((f) => f.path);
1543
+ debug("Running user checks on %d files...", allFiles.length);
1544
+ const ck = spinner();
1545
+ ck.start("Running checks...");
1546
+ const checkResults = await runAllChecks(repoRoot, allFiles, 6e4);
1547
+ debug("Check results: ok=%s, count=%d", checkResults.ok, checkResults.results.length);
1548
+ if (!checkResults.ok) {
1549
+ ck.stop(`${checkResults.results.filter((r) => !r.ok).length} check(s) failed`);
1550
+ const rawStderr = checkResults.results.filter((r) => !r.ok).map((r) => `[${r.tool}] ${r.stderr}`).join("\n");
1551
+ if (await showCheckFailureMenu(parseHookErrors(rawStderr), rawStderr) === "cancelled") return "cancelled";
1552
+ } else {
1553
+ ck.stop("All checks passed");
1554
+ if (checkResults.results.length > 0) log.info(checkResults.results.map((r) => ` ${green("✓")} ${r.tool}`).join("\n"));
1555
+ }
1556
+ }
1557
+ const config = await readConfig();
1558
+ const resolvedProvider = config.provider ?? "groq";
1559
+ const provider = isValidProvider(resolvedProvider) ? resolvedProvider : "groq";
1560
+ try {
1561
+ await getProviderApiKey(provider);
1562
+ debug("API key found");
1563
+ } catch {
1564
+ debug("No API key found, prompting user");
1565
+ const { text: promptText } = await import("@clack/prompts");
1566
+ const key = await promptText({
1567
+ message: `Enter your ${formatProviderName(provider)} API key:`,
1568
+ placeholder: provider === "groq" ? "gsk_..." : "...",
1569
+ validate: (v) => v?.trim() ? void 0 : "API key is required"
1570
+ });
1571
+ if (isCancel(key)) {
1572
+ outro(dim("Cancelled."));
1573
+ return "cancelled";
1574
+ }
1575
+ const configKey = PROVIDER_ENV_KEYS[provider];
1576
+ await setConfigValue(configKey, String(key).trim());
1577
+ debug("API key saved to config");
1578
+ }
1579
+ const s = spinner();
1580
+ s.start("Analyzing files...");
1581
+ 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);
1582
+ s.stop("Files analyzed");
1583
+ showGroupedFiles(validatedGroups, included);
1584
+ if (flags.auto) debug("Auto mode: skipping grouping confirmation");
1585
+ else if (!await showGroupingConfirmation(validatedGroups, excluded)) {
1586
+ outro(dim("Cancelled."));
1498
1587
  return "cancelled";
1499
1588
  }
1500
1589
  for (let i = 0; i < validatedGroups.length; i++) {
@@ -1558,14 +1647,19 @@ async function runAutoGroupFlow(changedFiles, flags) {
1558
1647
  }
1559
1648
  async function generateMessage(diff, hint) {
1560
1649
  const config = await readConfig();
1561
- const apiKey = await getApiKey();
1562
- debug("Generating message with model:", config.model, "type:", config.type);
1650
+ const resolvedProvider = config.provider ?? "groq";
1651
+ const provider = isValidProvider(resolvedProvider) ? resolvedProvider : "groq";
1652
+ const apiKey = await getProviderApiKey(provider);
1653
+ const model = getModelForProvider(config, provider, PROVIDER_CONFIGS[provider].defaultModel);
1654
+ debug("Generating message with provider:", provider, "model:", model, "type:", config.type);
1563
1655
  return generateCommitMessage(diff, {
1564
1656
  apiKey,
1565
- model: config.model,
1657
+ model,
1566
1658
  type: config.type,
1567
1659
  timeout: config.timeout ? parseInt(config.timeout, 10) : void 0,
1568
- hint
1660
+ hint,
1661
+ provider,
1662
+ proxy: config.proxy
1569
1663
  });
1570
1664
  }
1571
1665
  function buildExcludedFilesMessage(files) {
@@ -1578,46 +1672,143 @@ function buildExcludedFilesMessage(files) {
1578
1672
  return "chore: update generated files";
1579
1673
  }
1580
1674
  //#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);
1675
+ //#region src/commands/commit-utils.ts
1676
+ /** Shared recovery menu factory — avoids repeating the same callback set */
1677
+ function makeRecoveryCallbacks(message) {
1678
+ return {
1679
+ retry: async () => (await attemptCommit(message)).ok,
1680
+ skipHooks: async (msg) => (await attemptCommitNoVerify(msg)).ok,
1681
+ restage: async () => {
1682
+ await stageAll();
1683
+ return (await attemptCommit(message)).ok;
1684
+ },
1685
+ message
1686
+ };
1687
+ }
1688
+ /**
1689
+ * Attempt commit with automatic recovery flow.
1690
+ * Handles the attempt → HEAD check → success (tool checks display)
1691
+ * / failure (recovery menu) pattern.
1692
+ * Caller is responsible for starting the spinner and showing the final outro.
1693
+ */
1694
+ async function commitWithRecovery(message, s, headBefore) {
1695
+ const result = await attemptCommit(message, [], createProgressHandler(s));
1696
+ const headAfter = await getHead();
1697
+ if (result.ok || headBefore !== headAfter) {
1698
+ s.stop("Committed successfully.");
1699
+ const checks = parseToolChecks(result.stderr ?? "");
1700
+ if (checks.length > 0) {
1701
+ const lines = checks.map((c) => ` ${c.ok ? green("✓") : red("✗")} ${c.tool}`);
1702
+ log.info(lines.join("\n"));
1595
1703
  }
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"));
1704
+ return "committed";
1705
+ }
1706
+ s.stop("Commit failed.");
1707
+ const errors = parseHookErrors(result.stderr ?? "");
1708
+ const cb = makeRecoveryCallbacks(message);
1709
+ if (await showRecoveryMenu(errors, cb.retry, cb.skipHooks, cb.restage, cb.message, result.stderr ?? "") === "cancelled") return "cancelled";
1710
+ return "committed";
1711
+ }
1712
+ //#endregion
1713
+ //#region src/commands/retry.ts
1714
+ /** Handle --retry mode: load cached message and re-attempt commit */
1715
+ async function handleRetry() {
1716
+ debug("Entering retry mode");
1717
+ const cached = await loadCachedCommit(await getRepoRoot());
1718
+ if (!cached) {
1719
+ outro(red("No cached commit message found. Run cmint without --retry first."));
1720
+ process.exit(1);
1721
+ }
1722
+ intro("🌿 commit-mint — retry");
1723
+ const s = spinner();
1724
+ const headBefore = await getHead();
1725
+ s.start("Running pre-commit hooks...");
1726
+ if (await commitWithRecovery(cached.message, s, headBefore) === "committed") outro(green("Committed successfully."));
1727
+ else process.exit(1);
1728
+ }
1729
+ //#endregion
1730
+ //#region src/commands/staging.ts
1731
+ /** Interactive staging loop for multiple changed files */
1732
+ async function handleStaging(changedFiles, flags) {
1733
+ const repoRoot = await getRepoRoot();
1734
+ const checksAvailable = await detectConfig(repoRoot) !== null;
1735
+ debug("checks available:", checksAvailable);
1736
+ let stagingResult = null;
1737
+ let filesToStage = [];
1738
+ let stageAllFlag = false;
1739
+ let skipStaging = false;
1740
+ let currentFiles = changedFiles;
1741
+ while (true) {
1742
+ stagingResult = await showStagingMenu(currentFiles, checksAvailable);
1743
+ if (stagingResult === "autogroup") {
1744
+ if (flags.message) {
1745
+ outro(red("--message flag is not compatible with auto-group mode."));
1746
+ return null;
1608
1747
  }
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;
1748
+ if (await runAutoGroupFlow(currentFiles, flags) !== "committed") process.exit(1);
1749
+ return null;
1750
+ }
1751
+ if (stagingResult === "checks") {
1752
+ await stageAll();
1753
+ const ckSpinner = spinner();
1754
+ ckSpinner.start("Running checks...");
1755
+ const ckResult = await runAllChecks(repoRoot, currentFiles.filter((f) => f.status !== "D").map((f) => f.path), 6e4);
1756
+ if (ckResult.ok) {
1757
+ ckSpinner.stop("All checks passed");
1758
+ for (const r of ckResult.results) if (r.stdout.trim()) log.info(dim(r.stdout.trim()));
1759
+ } else {
1760
+ const failed = ckResult.results.filter((r) => !r.ok);
1761
+ ckSpinner.stop(`${failed.length} check${failed.length !== 1 ? "s" : ""} failed`);
1762
+ for (const r of failed) log.info(r.stderr?.trim() || r.stdout?.trim() || `Check failed: ${r.command}`);
1763
+ }
1764
+ currentFiles = await getChangedFiles();
1765
+ continue;
1766
+ }
1767
+ if (stagingResult === "staged") {
1768
+ skipStaging = true;
1769
+ break;
1770
+ }
1771
+ if (!stagingResult) {
1772
+ outro(dim("Cancelled."));
1773
+ return null;
1618
1774
  }
1775
+ filesToStage = stagingResult.files;
1776
+ stageAllFlag = stagingResult.all;
1777
+ break;
1619
1778
  }
1620
- intro("commit-mint");
1779
+ if (!skipStaging) {
1780
+ const s = spinner();
1781
+ s.start(`Staging ${filesToStage.length} file${filesToStage.length !== 1 ? "s" : ""}...`);
1782
+ if (stageAllFlag) await stageAll();
1783
+ else await stageFiles(filesToStage);
1784
+ s.stop("Files staged");
1785
+ }
1786
+ return {
1787
+ changedFiles: currentFiles,
1788
+ skipStaging
1789
+ };
1790
+ }
1791
+ /** Run user-defined pre-commit checks from cmint config */
1792
+ async function runPreCommitChecks(changedFiles, noCheck) {
1793
+ if (noCheck) return;
1794
+ const checkRoot = await getRepoRoot();
1795
+ const stagedFileList = changedFiles.filter((f) => f.staged && f.status !== "D").map((f) => f.path);
1796
+ if (stagedFileList.length === 0) return;
1797
+ debug("Running user checks on %d staged files...", stagedFileList.length);
1798
+ const checkResults = await runAllChecks(checkRoot, stagedFileList, 6e4);
1799
+ debug("Check results: ok=%s, count=%d", checkResults.ok, checkResults.results.length);
1800
+ if (!checkResults.ok) {
1801
+ const rawStderr = checkResults.results.filter((r) => !r.ok).map((r) => `[${r.tool}] ${r.stderr}`).join("\n");
1802
+ if (await showCheckFailureMenu(parseHookErrors(rawStderr), rawStderr) === "cancelled") process.exit(1);
1803
+ }
1804
+ }
1805
+ //#endregion
1806
+ //#region src/commands/commit.ts
1807
+ async function commitCommand(flags) {
1808
+ debug("commitCommand called", { flags });
1809
+ await assertGitRepo();
1810
+ if (flags.retry) return handleRetry();
1811
+ intro("🌿 commit-mint");
1621
1812
  const status = await getStatusShort();
1622
1813
  debug("Git status:", status || "(empty)");
1623
1814
  if (!status) {
@@ -1640,49 +1831,9 @@ async function commitCommand(flags) {
1640
1831
  await stageFiles([changedFiles[0].path]);
1641
1832
  s.stop("File staged");
1642
1833
  } 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");
1834
+ const result = await handleStaging(changedFiles, flags);
1835
+ if (!result) return;
1836
+ changedFiles = result.changedFiles;
1686
1837
  }
1687
1838
  } catch (err) {
1688
1839
  s.stop(red("Staging failed."));
@@ -1691,6 +1842,8 @@ async function commitCommand(flags) {
1691
1842
  outro(red(`Failed to stage files: ${msg}`));
1692
1843
  process.exit(1);
1693
1844
  }
1845
+ changedFiles = await getChangedFiles();
1846
+ await runPreCommitChecks(changedFiles, flags.noCheck);
1694
1847
  const diffResult = await getStagedDiff();
1695
1848
  if (!diffResult) {
1696
1849
  debug("No staged changes found after staging");
@@ -1701,22 +1854,14 @@ async function commitCommand(flags) {
1701
1854
  debug("All staged files are excluded:", diffResult.excludedFiles);
1702
1855
  const message = buildExcludedFilesMessage(diffResult.excludedFiles);
1703
1856
  log.info(diffResult.excludedFiles.map((f) => ` ${f}`).join("\n"));
1704
- const { getRepoRoot } = await Promise.resolve().then(() => git_exports);
1705
1857
  await saveCachedCommit(await getRepoRoot(), message);
1706
1858
  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.");
1859
+ const result = await commitWithRecovery(message, s, await getHead());
1860
+ if (result === "committed") {
1712
1861
  outro(green("Done."));
1713
1862
  return;
1714
1863
  }
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);
1864
+ if (result === "cancelled") process.exit(1);
1720
1865
  return;
1721
1866
  }
1722
1867
  debug("Staged files:", diffResult.files);
@@ -1727,22 +1872,25 @@ async function commitCommand(flags) {
1727
1872
  debug("Using provided message:", flags.message);
1728
1873
  message = flags.message;
1729
1874
  } else {
1875
+ const config = await readConfig();
1876
+ const provider = isValidProvider(config.provider ?? "groq") ? config.provider : "groq";
1730
1877
  try {
1731
- await getApiKey();
1878
+ await getProviderApiKey(provider);
1732
1879
  debug("API key found");
1733
1880
  } catch {
1734
1881
  debug("No API key found, prompting user");
1735
1882
  const { text: promptText } = await import("@clack/prompts");
1883
+ const configKey = PROVIDER_ENV_KEYS[provider];
1736
1884
  const key = await promptText({
1737
- message: "Enter your Groq API key:",
1738
- placeholder: "gsk_...",
1885
+ message: `Enter your ${formatProviderName(provider)} API key:`,
1886
+ placeholder: provider === "groq" ? "gsk_..." : "...",
1739
1887
  validate: (v) => v?.trim() ? void 0 : "API key is required"
1740
1888
  });
1741
1889
  if (isCancel(key)) {
1742
1890
  outro(dim("Cancelled."));
1743
1891
  return;
1744
1892
  }
1745
- await setConfigValue("GROQ_API_KEY", String(key).trim());
1893
+ await setConfigValue(configKey, String(key).trim());
1746
1894
  debug("API key saved to config");
1747
1895
  }
1748
1896
  s.start("Generating commit message...");
@@ -1765,66 +1913,197 @@ async function commitCommand(flags) {
1765
1913
  return;
1766
1914
  }
1767
1915
  message = reviewed;
1768
- const { getRepoRoot } = await Promise.resolve().then(() => git_exports);
1769
1916
  const repoRoot = await getRepoRoot();
1770
1917
  await saveCachedCommit(repoRoot, message);
1771
1918
  debug("Message cached for repo:", repoRoot);
1772
1919
  s.start("Running pre-commit hooks...");
1773
1920
  const headBefore = await getHead();
1774
1921
  debug("HEAD before commit:", headBefore);
1775
- const result = await attemptCommit(message, [], createProgressHandler(s));
1776
- const headAfter = await getHead();
1777
- debug("HEAD after commit:", headAfter);
1922
+ const result = await commitWithRecovery(message, s, headBefore);
1778
1923
  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
- }
1924
+ if (result === "committed") {
1786
1925
  outro(green("Done."));
1787
1926
  return;
1788
1927
  }
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);
1928
+ if (result === "cancelled") process.exit(1);
1801
1929
  }
1802
1930
  //#endregion
1803
1931
  //#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;
1932
+ function maskKey(key) {
1933
+ if (!key) return dim("not set");
1934
+ if (key.length <= 8) return "****";
1935
+ return `${key.slice(0, 4)}${"*".repeat(Math.min(key.length - 8, 20))}${key.slice(-4)}`;
1936
+ }
1937
+ function buildConfigDisplay(config) {
1938
+ const provider = isValidProvider(config.provider ?? "groq") ? config.provider : "groq";
1939
+ const apiKey = config[PROVIDER_ENV_KEYS[provider]];
1940
+ return [
1941
+ `Provider: ${bold(formatProviderName(provider))}`,
1942
+ `API Key: ${maskKey(apiKey)}`,
1943
+ `Model: ${config.model ?? "(none)"}`,
1944
+ `Locale: ${config.locale ?? "en"}`,
1945
+ `Max Length: ${config["max-length"] ?? "100"}`,
1946
+ `Commit Type: ${config.type || dim("(none)")}`,
1947
+ `Timeout: ${config.timeout ?? "10000"}ms`,
1948
+ `Proxy: ${config.proxy || dim("(none)")}`
1949
+ ].join("\n");
1950
+ }
1951
+ function getProvider(config) {
1952
+ return isValidProvider(config.provider ?? "groq") ? config.provider : "groq";
1953
+ }
1954
+ async function promptProvider() {
1955
+ return p.select({
1956
+ message: "Select LLM provider:",
1957
+ options: [
1958
+ {
1959
+ label: "Groq",
1960
+ value: "groq",
1961
+ hint: PROVIDER_CONFIGS.groq.defaultModel
1962
+ },
1963
+ {
1964
+ label: "Cerebras",
1965
+ value: "cerebras",
1966
+ hint: PROVIDER_CONFIGS.cerebras.defaultModel
1967
+ },
1968
+ {
1969
+ label: "Mistral",
1970
+ value: "mistral",
1971
+ hint: PROVIDER_CONFIGS.mistral.defaultModel
1972
+ }
1973
+ ]
1974
+ });
1975
+ }
1976
+ async function promptApiKey(provider) {
1977
+ const keyName = PROVIDER_ENV_KEYS[provider];
1978
+ const result = await p.text({
1979
+ message: `${formatProviderName(provider)} API key:`,
1980
+ placeholder: "Paste your API key",
1981
+ validate: (v) => !v?.trim() ? "API key cannot be empty" : void 0
1982
+ });
1983
+ if (p.isCancel(result)) return result;
1984
+ await writeConfig({ [keyName]: result.toString().trim() });
1985
+ debug("config: %s set", keyName);
1986
+ return result;
1987
+ }
1988
+ async function promptTextSetting(label, configKey, currentValue, validate) {
1989
+ const result = await p.text({
1990
+ message: label,
1991
+ placeholder: currentValue ?? "",
1992
+ initialValue: currentValue ?? "",
1993
+ validate
1994
+ });
1995
+ if (p.isCancel(result)) return result;
1996
+ await writeConfig({ [configKey]: result.toString().trim() });
1997
+ debug("config: %s set to %s", configKey, result);
1998
+ return result;
1999
+ }
2000
+ const requireNumber = (v) => {
2001
+ if (!v?.trim()) return "Value cannot be empty";
2002
+ return Number.isNaN(Number(v)) ? "Must be a number" : void 0;
2003
+ };
2004
+ function getSettingHandlers(config) {
2005
+ const provider = getProvider(config);
2006
+ return {
2007
+ provider: async () => {
2008
+ const result = await promptProvider();
2009
+ if (p.isCancel(result)) return result;
2010
+ await writeConfig({ provider: result });
2011
+ debug("config: provider set to %s", result);
2012
+ },
2013
+ apikey: async () => promptApiKey(provider),
2014
+ model: async () => promptTextSetting("Model ID:", "model", config.model),
2015
+ locale: async () => promptTextSetting("Locale (e.g. en, ja, ko):", "locale", config.locale),
2016
+ maxlen: async () => promptTextSetting("Max commit message length:", "max-length", config["max-length"], requireNumber),
2017
+ type: async () => promptTextSetting("Commit type prefix (e.g. conventional):", "type", config.type),
2018
+ timeout: async () => promptTextSetting("Timeout (ms):", "timeout", config.timeout, requireNumber),
2019
+ proxy: async () => promptTextSetting("Proxy URL:", "proxy", config.proxy)
2020
+ };
2021
+ }
2022
+ async function handleEditSetting(setting, config) {
2023
+ const handler = getSettingHandlers(config)[setting];
2024
+ if (!handler) return false;
2025
+ const result = await handler();
2026
+ return !p.isCancel(result);
2027
+ }
2028
+ async function editSettingsLoop(initialConfig) {
2029
+ let config = initialConfig;
2030
+ while (true) {
2031
+ config = await readConfig();
2032
+ const provider = getProvider(config);
2033
+ const setting = await p.select({
2034
+ message: "Select a setting to edit:",
2035
+ options: [
2036
+ {
2037
+ label: `LLM Provider ${dim(`(${formatProviderName(provider)})`)}`,
2038
+ value: "provider"
2039
+ },
2040
+ {
2041
+ label: `API Key ${dim(`(for ${formatProviderName(provider)})`)}`,
2042
+ value: "apikey"
2043
+ },
2044
+ {
2045
+ label: `Model ${dim(`(${config.model ?? "(none)"})`)}`,
2046
+ value: "model"
2047
+ },
2048
+ {
2049
+ label: `Locale ${dim(`(${config.locale ?? "en"})`)}`,
2050
+ value: "locale"
2051
+ },
2052
+ {
2053
+ label: `Max commit length ${dim(`(${config["max-length"] ?? "100"})`)}`,
2054
+ value: "maxlen"
2055
+ },
2056
+ {
2057
+ label: `Commit type prefix ${dim(`(${config.type || "(none)"})`)}`,
2058
+ value: "type"
2059
+ },
2060
+ {
2061
+ label: `Timeout (ms) ${dim(`(${config.timeout ?? "10000"})`)}`,
2062
+ value: "timeout"
2063
+ },
2064
+ {
2065
+ label: `Proxy URL ${dim(`(${config.proxy || "(none)"})`)}`,
2066
+ value: "proxy"
2067
+ },
2068
+ {
2069
+ label: "Done editing",
2070
+ value: "done"
2071
+ }
2072
+ ]
2073
+ });
2074
+ if (p.isCancel(setting) || setting === "done") break;
2075
+ if (await handleEditSetting(setting, config)) p.log.success(green("Updated."));
1816
2076
  }
1817
- if (mode === "set") {
1818
- for (const kv of keyValue) {
1819
- const [key, ...rest] = kv.split("=");
1820
- await setConfigValue(key, rest.join("="));
2077
+ }
2078
+ async function configCommand() {
2079
+ debug("configCommand: starting");
2080
+ p.intro(bold("🌿 commit-mint config"));
2081
+ while (true) {
2082
+ const config = await readConfig();
2083
+ p.note(buildConfigDisplay(config), "commit-mint config");
2084
+ const action = await p.select({
2085
+ message: "What would you like to do?",
2086
+ options: [{
2087
+ label: "Edit settings",
2088
+ value: "edit"
2089
+ }, {
2090
+ label: "Done",
2091
+ value: "done"
2092
+ }]
2093
+ });
2094
+ if (p.isCancel(action)) {
2095
+ debug("configCommand: cancelled at main menu");
2096
+ p.outro(dim("Cancelled."));
2097
+ return;
1821
2098
  }
1822
- console.log("Config updated.");
1823
- return;
2099
+ if (action === "done") {
2100
+ debug("configCommand: done");
2101
+ p.outro("Config saved.");
2102
+ return;
2103
+ }
2104
+ await editSettingsLoop(config);
1824
2105
  }
1825
- console.error(`Unknown config mode: ${mode}. Use "get" or "set".`);
1826
- process.exit(1);
1827
- });
2106
+ }
1828
2107
  //#endregion
1829
2108
  //#region src/cli.ts
1830
2109
  const { version } = package_default;
@@ -1855,24 +2134,25 @@ cli({
1855
2134
  description: "Add context hint for AI commit message generation",
1856
2135
  alias: "H"
1857
2136
  },
1858
- review: {
1859
- type: Boolean,
1860
- description: "Review staged changes with a coding model",
1861
- alias: "R",
1862
- default: false
1863
- },
1864
2137
  debug: {
1865
2138
  type: Boolean,
1866
2139
  description: "Enable debug output",
1867
2140
  alias: "d",
1868
2141
  default: false
2142
+ },
2143
+ noCheck: {
2144
+ type: Boolean,
2145
+ description: "Skip user-defined pre-commit checks",
2146
+ alias: "N",
2147
+ default: false
1869
2148
  }
1870
2149
  },
1871
- commands: [configCommand]
2150
+ commands: [command({ name: "config" }, async () => {
2151
+ await configCommand();
2152
+ })]
1872
2153
  }, (argv) => {
1873
2154
  setDebug(argv.flags.debug);
1874
- if (argv.flags.review) reviewCommand();
1875
- else commitCommand(argv.flags);
2155
+ commitCommand(argv.flags);
1876
2156
  });
1877
2157
  //#endregion
1878
2158
  export {};