@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/README.md +1 -14
- package/dist/cli.mjs +203 -396
- package/dist/cli.mjs.map +1 -1
- package/package.json +1 -1
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,
|
|
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.
|
|
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
|
|
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
|
-
|
|
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/
|
|
571
|
-
async function
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
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
|
-
|
|
600
|
-
|
|
601
|
-
|
|
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
|
-
|
|
605
|
-
|
|
606
|
-
|
|
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
|
|
1519
|
-
function
|
|
1520
|
-
|
|
1521
|
-
const lines =
|
|
1522
|
-
|
|
1523
|
-
|
|
1524
|
-
|
|
1525
|
-
|
|
1526
|
-
|
|
1527
|
-
|
|
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
|
-
|
|
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: ${
|
|
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
|
-
|
|
2257
|
-
|
|
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 () =>
|
|
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(`(${
|
|
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
|
-
|
|
2408
|
-
else commitCommand(argv.flags);
|
|
2215
|
+
commitCommand(argv.flags);
|
|
2409
2216
|
});
|
|
2410
2217
|
//#endregion
|
|
2411
2218
|
export {};
|