@kyubiware/commit-mint 0.5.0 → 0.5.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.mjs CHANGED
@@ -1,7 +1,7 @@
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";
@@ -9,10 +9,10 @@ import { extname, join } from "node:path";
9
9
  import ini from "ini";
10
10
  import Groq from "groq-sdk";
11
11
  import { execa } from "execa";
12
- import { spawn } from "node:child_process";
13
12
  import { createHash } from "node:crypto";
14
13
  import { readFileSync } from "node:fs";
15
14
  import picomatch from "picomatch";
15
+ import { spawn } from "node:child_process";
16
16
  //#region \0rolldown/runtime.js
17
17
  var __defProp = Object.defineProperty;
18
18
  var __exportAll = (all, no_symbols) => {
@@ -28,7 +28,7 @@ var __exportAll = (all, no_symbols) => {
28
28
  //#region package.json
29
29
  var package_default = {
30
30
  name: "@kyubiware/commit-mint",
31
- version: "0.5.0",
31
+ version: "0.5.2",
32
32
  description: "🌿 A commit tool that actually handles hook failures",
33
33
  type: "module",
34
34
  bin: { "cmint": "./dist/cli.mjs" },
@@ -104,11 +104,11 @@ const PROVIDER_CONFIGS = {
104
104
  defaultModel: "openai/gpt-oss-20b"
105
105
  },
106
106
  cerebras: {
107
- baseURL: "https://api.cerebras.ai",
107
+ baseURL: "https://api.cerebras.ai/v1",
108
108
  defaultModel: "gpt-oss-120b"
109
109
  },
110
110
  mistral: {
111
- baseURL: "https://api.mistral.ai",
111
+ baseURL: "https://api.mistral.ai/v1",
112
112
  defaultModel: "mistral-small"
113
113
  }
114
114
  };
@@ -124,17 +124,52 @@ function formatProviderName(provider) {
124
124
  function isValidProvider(name) {
125
125
  return ALLOWED_PROVIDERS.includes(name);
126
126
  }
127
+ /**
128
+ * Generic OpenAI-compatible chat completions client using fetch.
129
+ * Used for non-Groq providers where the Groq SDK's hardcoded `/openai/v1/` path
130
+ * prefix doesn't match the provider's actual API path.
131
+ */
132
+ function createFetchClient(baseURL, apiKey, timeout) {
133
+ return { chat: { completions: { async create(params) {
134
+ const url = `${baseURL}/chat/completions`;
135
+ debug("fetchClient: POST %s, model=%s", url, params.model);
136
+ const controller = new AbortController();
137
+ const timer = setTimeout(() => controller.abort(), timeout);
138
+ try {
139
+ const response = await fetch(url, {
140
+ method: "POST",
141
+ headers: {
142
+ "Content-Type": "application/json",
143
+ Authorization: `Bearer ${apiKey}`
144
+ },
145
+ body: JSON.stringify(params),
146
+ signal: controller.signal
147
+ });
148
+ if (!response.ok) {
149
+ const text = await response.text().catch(() => "");
150
+ throw new Error(`${response.status} ${text}`);
151
+ }
152
+ return await response.json();
153
+ } finally {
154
+ clearTimeout(timer);
155
+ }
156
+ } } } };
157
+ }
127
158
  function createProvider(options) {
128
159
  if (!isValidProvider(options.provider)) throw new Error(`Invalid provider "${options.provider}". Allowed values: ${ALLOWED_PROVIDERS.join(", ")}`);
129
160
  const providerConfig = PROVIDER_CONFIGS[options.provider];
130
161
  const model = options.modelOverride ?? providerConfig.defaultModel;
131
162
  const baseURL = options.baseURLOverride ?? providerConfig.baseURL;
163
+ const timeout = options.timeout ?? 6e4;
164
+ let client;
165
+ if (options.provider === "groq") client = new Groq({
166
+ apiKey: options.apiKey,
167
+ baseURL,
168
+ timeout
169
+ });
170
+ else client = createFetchClient(baseURL, options.apiKey, timeout);
132
171
  return {
133
- client: new Groq({
134
- apiKey: options.apiKey,
135
- baseURL,
136
- timeout: options.timeout
137
- }),
172
+ client,
138
173
  model
139
174
  };
140
175
  }
@@ -191,8 +226,17 @@ async function getProviderApiKey(provider) {
191
226
  debug("getProviderApiKey(%s): not found", provider);
192
227
  throw new Error(`Please set your ${formatProviderName(provider)} API key via \`cmint config set ${envVar}=<your token>\``);
193
228
  }
229
+ /** Check if a model name is the default for a provider OTHER than the given one. */
230
+ function isOtherProviderDefault(model, provider) {
231
+ for (const [name, config] of Object.entries(PROVIDER_CONFIGS)) if (name !== provider && config.defaultModel === model) return true;
232
+ return false;
233
+ }
194
234
  function getModelForProvider(config, provider, defaultModel) {
195
- return config[`model_${provider}`] ?? config.model ?? defaultModel;
235
+ const providerModel = config[`model_${provider}`];
236
+ if (providerModel) return providerModel;
237
+ const globalModel = config.model;
238
+ if (globalModel && !isOtherProviderDefault(globalModel, provider)) return globalModel;
239
+ return defaultModel;
196
240
  }
197
241
  //#endregion
198
242
  //#region src/services/hooks.ts
@@ -567,43 +611,80 @@ async function attemptCommitNoVerify(message, onProgress) {
567
611
  return attemptCommit(message, ["--no-verify"], onProgress);
568
612
  }
569
613
  //#endregion
570
- //#region src/services/clipboard.ts
571
- async function copyToClipboard(content) {
572
- for (const [cmd, args] of [
573
- ["wl-copy", []],
574
- ["xclip", ["-selection", "clipboard"]],
575
- ["xsel", ["--clipboard", "--input"]],
576
- ["pbcopy", []]
577
- ]) try {
578
- if (await new Promise((resolve) => {
579
- const child = spawn(cmd, args, { stdio: [
580
- "pipe",
581
- "ignore",
582
- "ignore"
583
- ] });
584
- let settled = false;
585
- const done = (result) => {
586
- if (settled) return;
587
- settled = true;
588
- resolve(result);
589
- };
590
- child.on("error", () => done(false));
591
- child.on("exit", (code) => {
592
- if (code !== 0) done(false);
593
- });
594
- child.stdin.write(content, (err) => {
595
- if (err) {
596
- done(false);
597
- return;
614
+ //#region src/ui/review-message.ts
615
+ async function reviewCommitMessage(message) {
616
+ const { select, text } = await import("@clack/prompts");
617
+ while (true) {
618
+ const review = await select({
619
+ message: `Review commit message:\n\n ${bold(message)}\n`,
620
+ options: [
621
+ {
622
+ label: "Use as-is",
623
+ value: "use"
624
+ },
625
+ {
626
+ label: "Edit",
627
+ value: "edit"
628
+ },
629
+ {
630
+ label: "Cancel",
631
+ value: "cancel"
598
632
  }
599
- child.stdin.end(() => {
600
- child.unref();
601
- done(true);
602
- });
633
+ ]
634
+ });
635
+ if (isCancel(review) || review === "cancel") {
636
+ debug("User cancelled at review step");
637
+ return null;
638
+ }
639
+ if (review === "use") {
640
+ debug("User accepted message");
641
+ return message;
642
+ }
643
+ if (review === "edit") {
644
+ debug("User chose to edit message");
645
+ const edited = await text({
646
+ message: "Edit commit message:",
647
+ initialValue: message,
648
+ validate: (v) => v?.trim() ? void 0 : "Message cannot be empty"
603
649
  });
604
- })) return true;
605
- } catch {}
606
- return false;
650
+ if (isCancel(edited)) continue;
651
+ message = String(edited).trim();
652
+ debug("Edited message:", message);
653
+ }
654
+ }
655
+ }
656
+ //#endregion
657
+ //#region src/utils/cache.ts
658
+ const CACHE_DIR = join(os.homedir(), ".cache", "commit-mint");
659
+ function repoHash(repoPath) {
660
+ return createHash("sha256").update(repoPath).digest("hex").slice(0, 12);
661
+ }
662
+ function cachePath(repoPath) {
663
+ return join(CACHE_DIR, `${repoHash(repoPath)}.json`);
664
+ }
665
+ async function saveCachedCommit(repoPath, message) {
666
+ await mkdir(CACHE_DIR, { recursive: true });
667
+ const data = {
668
+ message,
669
+ timestamp: Date.now(),
670
+ repoPath
671
+ };
672
+ const path = cachePath(repoPath);
673
+ debug("saveCachedCommit: saving to %s", path);
674
+ await writeFile(path, JSON.stringify(data, null, 2), "utf8");
675
+ }
676
+ async function loadCachedCommit(repoPath) {
677
+ const path = cachePath(repoPath);
678
+ debug("loadCachedCommit: loading from %s", path);
679
+ try {
680
+ const raw = await readFile(path, "utf8");
681
+ const data = JSON.parse(raw);
682
+ debug("loadCachedCommit: found message from %s", new Date(data.timestamp).toISOString());
683
+ return data;
684
+ } catch {
685
+ debug("loadCachedCommit: no cached commit found");
686
+ return null;
687
+ }
607
688
  }
608
689
  //#endregion
609
690
  //#region src/services/ai.ts
@@ -614,6 +695,7 @@ function mapGroqError(error, providerLabel) {
614
695
  if (error instanceof Groq.RateLimitError) return /* @__PURE__ */ new Error(`Rate limited by ${label}. Please wait and try again.`);
615
696
  if (error instanceof Groq.APIConnectionTimeoutError) return /* @__PURE__ */ new Error("Request timed out. Check your network or try a smaller diff.");
616
697
  if (error instanceof Groq.APIError) return /* @__PURE__ */ new Error(`${label} API error: ${error.message}`);
698
+ if (error instanceof Error && /^4\d{2}\s/.test(error.message)) return /* @__PURE__ */ new Error(`${label} API error: ${error.message}`);
617
699
  return /* @__PURE__ */ new Error(`Unexpected error: ${error instanceof Error ? error.message : String(error)}`);
618
700
  }
619
701
  const CONVENTIONAL_COMMIT_REGEX = /^(build|chore|ci|docs|feat|fix|perf|refactor|revert|style|test)(\(.+\))?!?: .+$/;
@@ -720,6 +802,7 @@ async function generateCommitMessage(diff, options) {
720
802
  debug("callAI: %s — model=%s, promptLen=%d, systemLen=%d", !!strictSystemPrompt ? "RETRY (strict)" : "INITIAL", model, userPrompt.length, (strictSystemPrompt ?? systemPrompt).length);
721
803
  try {
722
804
  const isReasoningModel = /^(o[1-9]|.*gpt-oss.*|.*gpt-5.*)/i.test(model);
805
+ const isGroq = (options.provider ?? "groq") === "groq";
723
806
  const completion = await client.chat.completions.create({
724
807
  messages: [{
725
808
  role: "system",
@@ -731,7 +814,7 @@ async function generateCommitMessage(diff, options) {
731
814
  model,
732
815
  temperature: .3,
733
816
  ...isReasoningModel ? { max_completion_tokens: 1024 } : { max_tokens: 1024 },
734
- reasoning_format: "parsed"
817
+ ...isGroq && isReasoningModel ? { reasoning_format: "parsed" } : {}
735
818
  });
736
819
  const elapsed = Date.now() - callStart;
737
820
  const rawContent = completion.choices[0]?.message?.content;
@@ -778,330 +861,6 @@ async function generateCommitMessage(diff, options) {
778
861
  }
779
862
  }
780
863
  //#endregion
781
- //#region src/services/review-ai.ts
782
- function buildReviewSystemPrompt() {
783
- return [
784
- "You are an expert code reviewer. Review the following staged git diff.",
785
- "",
786
- "Focus on finding:",
787
- "1. **Bugs** — logic errors, off-by-one, race conditions, null pointer risks",
788
- "2. **Security issues** — injection, exposure of secrets, missing validation, CSRF, XSS",
789
- "3. **Performance problems** — unnecessary work, large allocations in hot paths",
790
- "4. **Code quality** — readability, maintainability, error handling gaps",
791
- "5. **Edge cases** — missing boundary checks, empty states, error states",
792
- "",
793
- "For each issue found, use this format:",
794
- "- SEVERITY: [critical|major|minor|suggestion]",
795
- "- LOCATION: <file-path>:<line-number>",
796
- "- ISSUE: <description>",
797
- "- FIX: <suggested resolution>",
798
- "",
799
- "Separate issues with a blank line.",
800
- "",
801
- "If you find NO issues at all, respond with exactly: NO_ISSUES_FOUND",
802
- "",
803
- "Be thorough but practical. Only flag real problems — not style preferences or nitpicks."
804
- ].join("\n");
805
- }
806
- function buildReviewPrompt(diff, files, statSummary) {
807
- const parts = [];
808
- parts.push(`Review the following staged changes (${files.length} files):`);
809
- parts.push("");
810
- parts.push(statSummary);
811
- parts.push("");
812
- parts.push("```diff");
813
- parts.push(diff);
814
- parts.push("```");
815
- return parts.join("\n");
816
- }
817
- async function generateCodeReview(diff, files, options) {
818
- debug("generateCodeReview: model=%s, files=%d", options.model ?? "default", files.length);
819
- const timeoutMs = options.timeout ?? 6e4;
820
- const { client, model } = createProvider({
821
- provider: options.provider ?? "groq",
822
- apiKey: options.apiKey,
823
- modelOverride: options.model,
824
- timeout: timeoutMs,
825
- baseURLOverride: options.proxy
826
- });
827
- const compressedDiff = compressDiff(diff);
828
- const statSummary = buildStatSummary(diff);
829
- const systemPrompt = buildReviewSystemPrompt();
830
- const userPrompt = buildReviewPrompt(compressedDiff, files, statSummary);
831
- debug("Code review: %d chars → %d chars, system=%d chars, user=%d chars", diff.length, compressedDiff.length, systemPrompt.length, userPrompt.length);
832
- try {
833
- const completion = await client.chat.completions.create({
834
- messages: [{
835
- role: "system",
836
- content: systemPrompt
837
- }, {
838
- role: "user",
839
- content: userPrompt
840
- }],
841
- model,
842
- temperature: .3,
843
- max_tokens: 4096
844
- });
845
- const rawContent = completion.choices[0]?.message?.content;
846
- const content = extractContentText(rawContent);
847
- debug("generateCodeReview response: choices=%d, finishReason=%s, contentLen=%d", completion.choices.length, completion.choices[0]?.finish_reason ?? "(none)", content.length);
848
- if (!content) {
849
- const reasoning = completion.choices[0]?.message?.reasoning;
850
- if (reasoning) {
851
- const derived = deriveMessageFromReasoning(reasoning);
852
- if (derived) {
853
- debug("generateCodeReview: derived from reasoning");
854
- return derived;
855
- }
856
- }
857
- return "NO_ISSUES_FOUND";
858
- }
859
- return content;
860
- } catch (error) {
861
- debug("generateCodeReview error: %s", error instanceof Error ? error.message : String(error));
862
- throw mapGroqError(error, options.provider ? formatProviderName(options.provider) : void 0);
863
- }
864
- }
865
- //#endregion
866
- //#region src/commands/review.ts
867
- async function reviewCommand() {
868
- debug("reviewCommand called");
869
- await assertGitRepo();
870
- const s = spinner();
871
- s.start("Staging all changes...");
872
- await stageAll();
873
- s.stop("Changes staged");
874
- const diffResult = await getStagedDiff();
875
- if (!diffResult) {
876
- outro(dim("No changes to review."));
877
- return;
878
- }
879
- if ("excludedFiles" in diffResult) {
880
- outro(dim("Staged files are all excluded from review."));
881
- return;
882
- }
883
- intro("🌿 commit-mint — code review");
884
- log.info(diffResult.files.map((f) => ` ${f}`).join("\n"));
885
- const report = await isOpenCodeAvailable() ? await reviewWithOpenCode(diffResult.diff, diffResult.files) : await reviewWithGroq(diffResult.diff, diffResult.files);
886
- if (report !== "NO_ISSUES_FOUND" && report.trim().length > 0) {
887
- note(report, red(bold("Review findings")));
888
- await offerClipboardCopy(report);
889
- } else outro(green("No issues found. Looks good!"));
890
- }
891
- async function offerClipboardCopy(report) {
892
- const shouldCopy = await select({
893
- message: "Copy review report to clipboard?",
894
- options: [{
895
- label: "Yes, copy to clipboard",
896
- value: "yes"
897
- }, {
898
- label: "No",
899
- value: "no"
900
- }]
901
- });
902
- if (isCancel(shouldCopy) || shouldCopy === "no") {
903
- outro(dim("Done."));
904
- return;
905
- }
906
- if (await copyToClipboard(report)) outro(green("Report copied to clipboard. You can paste it anywhere for fixes."));
907
- else outro(red("Failed to copy to clipboard. Install xclip, wl-copy, or xsel."));
908
- }
909
- async function isOpenCodeAvailable() {
910
- try {
911
- const { exitCode } = await import("execa").then((m) => m.execa("which", ["opencode"], { reject: false }));
912
- return exitCode === 0;
913
- } catch {
914
- return false;
915
- }
916
- }
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);
922
- const s = spinner();
923
- s.start(`Reviewing with ${formatProviderName(provider)}...`);
924
- try {
925
- const report = await generateCodeReview(diff, files, {
926
- apiKey,
927
- model,
928
- timeout: config.timeout ? Number.parseInt(config.timeout, 10) : void 0,
929
- provider,
930
- proxy: config.proxy
931
- });
932
- s.stop("Review complete");
933
- return report;
934
- } catch (err) {
935
- s.stop(red("Review failed."));
936
- debug("reviewWithGroq error:", err instanceof Error ? err.message : String(err));
937
- throw err;
938
- }
939
- }
940
- async function reviewWithOpenCode(diff, files) {
941
- const s = spinner();
942
- s.start("Running OpenCode review...");
943
- try {
944
- const repoRoot = await getRepoRoot();
945
- const prompt = [
946
- "Review the staged changes in this git repository.",
947
- "Analyze the code diff for bugs, security issues, performance problems,",
948
- "code quality issues, and missing edge cases.",
949
- "",
950
- `Files changed (${files.length}):`,
951
- ...files.map((f) => ` - ${f}`),
952
- "",
953
- "```diff",
954
- diff.slice(0, 15e3),
955
- "```",
956
- "",
957
- "Provide a structured report with severity, location, issue, and fix suggestion for each finding.",
958
- "If no issues found, respond with: NO_ISSUES_FOUND"
959
- ].join("\n");
960
- const { stdout } = await import("execa").then((m) => m.execa("opencode", [
961
- "run",
962
- prompt,
963
- "--dir",
964
- repoRoot
965
- ], {
966
- timeout: 12e4,
967
- reject: false
968
- }));
969
- s.stop("Review complete");
970
- return stdout || "OpenCode review completed but no output captured.";
971
- } catch (err) {
972
- s.stop(red("OpenCode review failed."));
973
- debug("reviewWithOpenCode error:", err instanceof Error ? err.message : String(err));
974
- throw new Error(`OpenCode review failed: ${err instanceof Error ? err.message : String(err)}`);
975
- }
976
- }
977
- //#endregion
978
- //#region src/ui/review-message.ts
979
- async function runCodeReview() {
980
- const diffResult = await getStagedDiff();
981
- if (!diffResult || "excludedFiles" in diffResult) {
982
- outro(dim("No staged changes to review."));
983
- return;
984
- }
985
- const opencodeAvailable = await isOpenCodeAvailable();
986
- const config = await readConfig();
987
- const provider = isValidProvider(config.provider ?? "groq") ? config.provider : "groq";
988
- const s = spinner();
989
- s.start(opencodeAvailable ? "Running OpenCode review..." : `Running ${formatProviderName(provider)} review...`);
990
- try {
991
- const report = opencodeAvailable ? await reviewWithOpenCode(diffResult.diff, diffResult.files) : await reviewWithGroq(diffResult.diff, diffResult.files);
992
- s.stop("Review complete");
993
- await showReviewResults(report);
994
- } catch (err) {
995
- s.stop(red("Review failed."));
996
- debug("Code review error:", err instanceof Error ? err.message : String(err));
997
- outro(red(err instanceof Error ? err.message : String(err)));
998
- }
999
- }
1000
- async function showReviewResults(report) {
1001
- const { note: clackNote, select: clackSelect } = await import("@clack/prompts");
1002
- if (!(report !== "NO_ISSUES_FOUND" && report.trim().length > 0)) {
1003
- log.info(green("No issues found."));
1004
- return;
1005
- }
1006
- clackNote(report, red(bold("Review findings")));
1007
- const shouldCopy = await clackSelect({
1008
- message: "Copy review report to clipboard?",
1009
- options: [{
1010
- label: "Yes, copy to clipboard",
1011
- value: "yes"
1012
- }, {
1013
- label: "No",
1014
- value: "no"
1015
- }]
1016
- });
1017
- if (isCancel(shouldCopy) || shouldCopy !== "yes") return;
1018
- if (await copyToClipboard(report)) log.info(green("Report copied to clipboard."));
1019
- else log.warn(red("Failed to copy to clipboard."));
1020
- }
1021
- async function reviewCommitMessage(message) {
1022
- const { select, text } = await import("@clack/prompts");
1023
- while (true) {
1024
- const review = await select({
1025
- message: `Review commit message:\n\n ${bold(message)}\n`,
1026
- options: [
1027
- {
1028
- label: "Use as-is",
1029
- value: "use"
1030
- },
1031
- {
1032
- label: "Edit",
1033
- value: "edit"
1034
- },
1035
- {
1036
- label: "Review with OpenCode",
1037
- value: "review"
1038
- },
1039
- {
1040
- label: "Cancel",
1041
- value: "cancel"
1042
- }
1043
- ]
1044
- });
1045
- if (isCancel(review) || review === "cancel") {
1046
- debug("User cancelled at review step");
1047
- return null;
1048
- }
1049
- if (review === "use") {
1050
- debug("User accepted message");
1051
- return message;
1052
- }
1053
- if (review === "edit") {
1054
- debug("User chose to edit message");
1055
- const edited = await text({
1056
- message: "Edit commit message:",
1057
- initialValue: message,
1058
- validate: (v) => v?.trim() ? void 0 : "Message cannot be empty"
1059
- });
1060
- if (isCancel(edited)) continue;
1061
- message = String(edited).trim();
1062
- debug("Edited message:", message);
1063
- continue;
1064
- }
1065
- if (review === "review") {
1066
- debug("User chose to review code");
1067
- await runCodeReview();
1068
- }
1069
- }
1070
- }
1071
- //#endregion
1072
- //#region src/utils/cache.ts
1073
- const CACHE_DIR = join(os.homedir(), ".cache", "commit-mint");
1074
- function repoHash(repoPath) {
1075
- return createHash("sha256").update(repoPath).digest("hex").slice(0, 12);
1076
- }
1077
- function cachePath(repoPath) {
1078
- return join(CACHE_DIR, `${repoHash(repoPath)}.json`);
1079
- }
1080
- async function saveCachedCommit(repoPath, message) {
1081
- await mkdir(CACHE_DIR, { recursive: true });
1082
- const data = {
1083
- message,
1084
- timestamp: Date.now(),
1085
- repoPath
1086
- };
1087
- const path = cachePath(repoPath);
1088
- debug("saveCachedCommit: saving to %s", path);
1089
- await writeFile(path, JSON.stringify(data, null, 2), "utf8");
1090
- }
1091
- async function loadCachedCommit(repoPath) {
1092
- const path = cachePath(repoPath);
1093
- debug("loadCachedCommit: loading from %s", path);
1094
- try {
1095
- const raw = await readFile(path, "utf8");
1096
- const data = JSON.parse(raw);
1097
- debug("loadCachedCommit: found message from %s", new Date(data.timestamp).toISOString());
1098
- return data;
1099
- } catch {
1100
- debug("loadCachedCommit: no cached commit found");
1101
- return null;
1102
- }
1103
- }
1104
- //#endregion
1105
864
  //#region src/services/checks.ts
1106
865
  /** Config file names, checked in priority order (matches lint-staged naming conventions) */
1107
866
  const CONFIG_FILES = [
@@ -1515,19 +1274,61 @@ const statusLabel = (status) => {
1515
1274
  default: return dim(status);
1516
1275
  }
1517
1276
  };
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" : ""}`);
1277
+ /** Display combined view: files with status indicators grouped by commit group */
1278
+ function showGroupedFiles(groups, changedFiles) {
1279
+ const statusMap = new Map(changedFiles.map((f) => [f.path, f.status]));
1280
+ const lines = [];
1281
+ for (let i = 0; i < groups.length; i++) {
1282
+ const group = groups[i];
1283
+ lines.push(`${bold(group.name)} ${dim("—")} ${group.files.length} file${group.files.length !== 1 ? "s" : ""}`);
1284
+ for (const file of group.files) {
1285
+ const status = statusMap.get(file) ?? "M";
1286
+ lines.push(` ${statusLabel(status)} ${file}`);
1287
+ }
1288
+ if (i < groups.length - 1) lines.push("");
1289
+ }
1528
1290
  p.note(lines.join("\n"), "Commit groups");
1529
1291
  }
1530
1292
  //#endregion
1293
+ //#region src/services/clipboard.ts
1294
+ async function copyToClipboard(content) {
1295
+ for (const [cmd, args] of [
1296
+ ["wl-copy", []],
1297
+ ["xclip", ["-selection", "clipboard"]],
1298
+ ["xsel", ["--clipboard", "--input"]],
1299
+ ["pbcopy", []]
1300
+ ]) try {
1301
+ if (await new Promise((resolve) => {
1302
+ const child = spawn(cmd, args, { stdio: [
1303
+ "pipe",
1304
+ "ignore",
1305
+ "ignore"
1306
+ ] });
1307
+ let settled = false;
1308
+ const done = (result) => {
1309
+ if (settled) return;
1310
+ settled = true;
1311
+ resolve(result);
1312
+ };
1313
+ child.on("error", () => done(false));
1314
+ child.on("exit", (code) => {
1315
+ if (code !== 0) done(false);
1316
+ });
1317
+ child.stdin.write(content, (err) => {
1318
+ if (err) {
1319
+ done(false);
1320
+ return;
1321
+ }
1322
+ child.stdin.end(() => {
1323
+ child.unref();
1324
+ done(true);
1325
+ });
1326
+ });
1327
+ })) return true;
1328
+ } catch {}
1329
+ return false;
1330
+ }
1331
+ //#endregion
1531
1332
  //#region src/ui/menu.ts
1532
1333
  async function showStagingMenu(files, hasChecks) {
1533
1334
  debug("showStagingMenu: %d files", files.length);
@@ -1825,8 +1626,7 @@ async function runAutoGroupFlow(changedFiles, flags) {
1825
1626
  s.start("Analyzing files...");
1826
1627
  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);
1827
1628
  s.stop("Files analyzed");
1828
- showChangedFilesTable(included);
1829
- showGroupingSummary(validatedGroups);
1629
+ showGroupedFiles(validatedGroups, included);
1830
1630
  if (flags.auto) debug("Auto mode: skipping grouping confirmation");
1831
1631
  else if (!await showGroupingConfirmation(validatedGroups, excluded)) {
1832
1632
  outro(dim("Cancelled."));
@@ -2183,10 +1983,11 @@ function maskKey(key) {
2183
1983
  function buildConfigDisplay(config) {
2184
1984
  const provider = isValidProvider(config.provider ?? "groq") ? config.provider : "groq";
2185
1985
  const apiKey = config[PROVIDER_ENV_KEYS[provider]];
1986
+ const effectiveModel = getModelForProvider(config, provider, PROVIDER_CONFIGS[provider].defaultModel);
2186
1987
  return [
2187
1988
  `Provider: ${bold(formatProviderName(provider))}`,
2188
1989
  `API Key: ${maskKey(apiKey)}`,
2189
- `Model: ${config.model ?? "(none)"}`,
1990
+ `Model: ${effectiveModel}`,
2190
1991
  `Locale: ${config.locale ?? "en"}`,
2191
1992
  `Max Length: ${config["max-length"] ?? "100"}`,
2192
1993
  `Commit Type: ${config.type || dim("(none)")}`,
@@ -2253,11 +2054,23 @@ function getSettingHandlers(config) {
2253
2054
  provider: async () => {
2254
2055
  const result = await promptProvider();
2255
2056
  if (p.isCancel(result)) return result;
2256
- await writeConfig({ provider: result });
2257
- debug("config: provider set to %s", result);
2057
+ const newProvider = result;
2058
+ const newDefaultModel = PROVIDER_CONFIGS[newProvider].defaultModel;
2059
+ await writeConfig({
2060
+ provider: newProvider,
2061
+ model: newDefaultModel
2062
+ });
2063
+ debug("config: provider set to %s, model set to %s", newProvider, newDefaultModel);
2064
+ const keyName = PROVIDER_ENV_KEYS[newProvider];
2065
+ if (!(await readConfig())[keyName]) {
2066
+ const keyResult = await promptApiKey(newProvider);
2067
+ if (p.isCancel(keyResult)) return keyResult;
2068
+ }
2258
2069
  },
2259
2070
  apikey: async () => promptApiKey(provider),
2260
- model: async () => promptTextSetting("Model ID:", "model", config.model),
2071
+ model: async () => {
2072
+ return promptTextSetting("Model ID:", "model", getModelForProvider(config, provider, PROVIDER_CONFIGS[provider].defaultModel));
2073
+ },
2261
2074
  locale: async () => promptTextSetting("Locale (e.g. en, ja, ko):", "locale", config.locale),
2262
2075
  maxlen: async () => promptTextSetting("Max commit message length:", "max-length", config["max-length"], requireNumber),
2263
2076
  type: async () => promptTextSetting("Commit type prefix (e.g. conventional):", "type", config.type),
@@ -2276,6 +2089,7 @@ async function editSettingsLoop(initialConfig) {
2276
2089
  while (true) {
2277
2090
  config = await readConfig();
2278
2091
  const provider = getProvider(config);
2092
+ const effectiveModel = getModelForProvider(config, provider, PROVIDER_CONFIGS[provider].defaultModel);
2279
2093
  const setting = await p.select({
2280
2094
  message: "Select a setting to edit:",
2281
2095
  options: [
@@ -2288,7 +2102,7 @@ async function editSettingsLoop(initialConfig) {
2288
2102
  value: "apikey"
2289
2103
  },
2290
2104
  {
2291
- label: `Model ${dim(`(${config.model ?? "(none)"})`)}`,
2105
+ label: `Model ${dim(`(${effectiveModel})`)}`,
2292
2106
  value: "model"
2293
2107
  },
2294
2108
  {
@@ -2380,12 +2194,6 @@ cli({
2380
2194
  description: "Add context hint for AI commit message generation",
2381
2195
  alias: "H"
2382
2196
  },
2383
- review: {
2384
- type: Boolean,
2385
- description: "Review staged changes with a coding model",
2386
- alias: "R",
2387
- default: false
2388
- },
2389
2197
  debug: {
2390
2198
  type: Boolean,
2391
2199
  description: "Enable debug output",
@@ -2404,8 +2212,7 @@ cli({
2404
2212
  })]
2405
2213
  }, (argv) => {
2406
2214
  setDebug(argv.flags.debug);
2407
- if (argv.flags.review) reviewCommand();
2408
- else commitCommand(argv.flags);
2215
+ commitCommand(argv.flags);
2409
2216
  });
2410
2217
  //#endregion
2411
2218
  export {};