@kud/ai-conventional-commit-cli 0.2.1 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +55 -17
- package/dist/index.cjs +223 -52
- package/dist/index.js +506 -160
- package/package.json +3 -3
package/dist/index.js
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
import { Cli, Command, Option } from "clipanion";
|
|
5
5
|
|
|
6
6
|
// src/workflow/generate.ts
|
|
7
|
-
import
|
|
7
|
+
import chalk2 from "chalk";
|
|
8
8
|
import ora from "ora";
|
|
9
9
|
|
|
10
10
|
// src/git.ts
|
|
@@ -89,6 +89,17 @@ var createCommit = async (title, body) => {
|
|
|
89
89
|
await git.commit(title);
|
|
90
90
|
}
|
|
91
91
|
};
|
|
92
|
+
var resetIndex = async () => {
|
|
93
|
+
await git.reset(["--mixed"]);
|
|
94
|
+
};
|
|
95
|
+
var stageFiles = async (files) => {
|
|
96
|
+
if (!files.length) return;
|
|
97
|
+
await git.add(files);
|
|
98
|
+
};
|
|
99
|
+
var getStagedFiles = async () => {
|
|
100
|
+
const status = await git.status();
|
|
101
|
+
return status.staged;
|
|
102
|
+
};
|
|
92
103
|
|
|
93
104
|
// src/style.ts
|
|
94
105
|
var buildStyleProfile = (messages) => {
|
|
@@ -171,7 +182,7 @@ var buildGenerationMessages = (opts) => {
|
|
|
171
182
|
);
|
|
172
183
|
specLines.push("Locale: en");
|
|
173
184
|
specLines.push(
|
|
174
|
-
'Output JSON Schema: { "commits": [ { "title": string, "body": string, "score": 0-100, "reasons": string[] } ], "meta": { "splitRecommended": boolean } }'
|
|
185
|
+
'Output JSON Schema: { "commits": [ { "title": string, "body": string, "score": 0-100, "reasons": string[], "files"?: string[] } ], "meta": { "splitRecommended": boolean } }'
|
|
175
186
|
);
|
|
176
187
|
specLines.push("Primary Output Field: commits[ ].title");
|
|
177
188
|
specLines.push("Title Format: <type>(<optional-scope>): <subject>");
|
|
@@ -196,7 +207,7 @@ var buildGenerationMessages = (opts) => {
|
|
|
196
207
|
"Provide reasons array citing concrete diff elements: filenames, functions, tests, metrics."
|
|
197
208
|
);
|
|
198
209
|
specLines.push(
|
|
199
|
-
|
|
210
|
+
'When mode is split, WHERE POSSIBLE add a "files" array per commit listing the most relevant changed file paths (1-6, minimize overlap across commits).'
|
|
200
211
|
);
|
|
201
212
|
specLines.push("Return ONLY the JSON object. No surrounding text or markdown.");
|
|
202
213
|
specLines.push("Do not add fields not listed in schema.");
|
|
@@ -352,7 +363,8 @@ var CommitSchema = z.object({
|
|
|
352
363
|
title: z.string().min(5).max(150),
|
|
353
364
|
body: z.string().optional().default(""),
|
|
354
365
|
score: z.number().min(0).max(100),
|
|
355
|
-
reasons: z.array(z.string()).optional().default([])
|
|
366
|
+
reasons: z.array(z.string()).optional().default([]),
|
|
367
|
+
files: z.array(z.string()).optional().default([])
|
|
356
368
|
});
|
|
357
369
|
var PlanSchema = z.object({
|
|
358
370
|
commits: z.array(CommitSchema).min(1),
|
|
@@ -547,7 +559,149 @@ var formatCommitTitle = (raw, opts) => {
|
|
|
547
559
|
import { writeFileSync, mkdirSync, existsSync } from "fs";
|
|
548
560
|
import { join } from "path";
|
|
549
561
|
import inquirer from "inquirer";
|
|
562
|
+
|
|
563
|
+
// src/workflow/ui.ts
|
|
564
|
+
import chalk from "chalk";
|
|
565
|
+
function animateHeaderBase(text = "ai-conventional-commit") {
|
|
566
|
+
if (!process.stdout.isTTY || process.env.AICC_NO_ANIMATION) {
|
|
567
|
+
console.log("\n\u250C " + chalk.bold(text));
|
|
568
|
+
return Promise.resolve();
|
|
569
|
+
}
|
|
570
|
+
const palette = [
|
|
571
|
+
"#3a0d6d",
|
|
572
|
+
"#5a1ea3",
|
|
573
|
+
"#7a32d6",
|
|
574
|
+
"#9a4dff",
|
|
575
|
+
"#b267ff",
|
|
576
|
+
"#c37dff",
|
|
577
|
+
"#b267ff",
|
|
578
|
+
"#9a4dff",
|
|
579
|
+
"#7a32d6",
|
|
580
|
+
"#5a1ea3"
|
|
581
|
+
];
|
|
582
|
+
process.stdout.write("\n");
|
|
583
|
+
return palette.reduce(async (p, color) => {
|
|
584
|
+
await p;
|
|
585
|
+
const frame = chalk.bold.hex(color)(text);
|
|
586
|
+
process.stdout.write("\r\u250C " + frame);
|
|
587
|
+
await new Promise((r) => setTimeout(r, 60));
|
|
588
|
+
}, Promise.resolve()).then(() => process.stdout.write("\n"));
|
|
589
|
+
}
|
|
590
|
+
function borderLine(content) {
|
|
591
|
+
if (!content) console.log("\u2502");
|
|
592
|
+
else console.log("\u2502 " + content);
|
|
593
|
+
}
|
|
594
|
+
function sectionTitle(label) {
|
|
595
|
+
console.log("\u2299 " + chalk.bold(label));
|
|
596
|
+
}
|
|
597
|
+
function abortMessage() {
|
|
598
|
+
console.log("\u2514 \u{1F645}\u200D\u2640\uFE0F No commit created.");
|
|
599
|
+
}
|
|
600
|
+
function finalSuccess(opts) {
|
|
601
|
+
const elapsedMs = Date.now() - opts.startedAt;
|
|
602
|
+
const seconds = elapsedMs / 1e3;
|
|
603
|
+
const dur = seconds >= 0.1 ? seconds.toFixed(1) + "s" : elapsedMs + "ms";
|
|
604
|
+
const plural = opts.count !== 1;
|
|
605
|
+
if (plural) console.log(`\u2514 \u2728 ${opts.count} commits created in ${dur}.`);
|
|
606
|
+
else console.log(`\u2514 \u2728 commit created in ${dur}.`);
|
|
607
|
+
}
|
|
608
|
+
function createPhasedSpinner(oraLib) {
|
|
609
|
+
const useAnim = process.stdout.isTTY && !process.env.AICC_NO_ANIMATION && !process.env.AICC_NO_SPINNER_ANIM;
|
|
610
|
+
const palette = [
|
|
611
|
+
"#3a0d6d",
|
|
612
|
+
"#5a1ea3",
|
|
613
|
+
"#7a32d6",
|
|
614
|
+
"#9a4dff",
|
|
615
|
+
"#b267ff",
|
|
616
|
+
"#c37dff",
|
|
617
|
+
"#b267ff",
|
|
618
|
+
"#9a4dff",
|
|
619
|
+
"#7a32d6",
|
|
620
|
+
"#5a1ea3"
|
|
621
|
+
];
|
|
622
|
+
let label = "Starting";
|
|
623
|
+
let i = 0;
|
|
624
|
+
const spinner = oraLib({ text: chalk.bold(label), spinner: "dots" }).start();
|
|
625
|
+
let interval = null;
|
|
626
|
+
function frame() {
|
|
627
|
+
if (!useAnim) return;
|
|
628
|
+
spinner.text = chalk.bold.hex(palette[i])(label);
|
|
629
|
+
i = (i + 1) % palette.length;
|
|
630
|
+
}
|
|
631
|
+
if (useAnim) {
|
|
632
|
+
frame();
|
|
633
|
+
interval = setInterval(frame, 80);
|
|
634
|
+
}
|
|
635
|
+
function setLabel(next) {
|
|
636
|
+
label = next;
|
|
637
|
+
if (useAnim) {
|
|
638
|
+
i = 0;
|
|
639
|
+
frame();
|
|
640
|
+
} else {
|
|
641
|
+
spinner.text = chalk.bold(label);
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
function stopAnim() {
|
|
645
|
+
if (interval) {
|
|
646
|
+
clearInterval(interval);
|
|
647
|
+
interval = null;
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
return {
|
|
651
|
+
spinner,
|
|
652
|
+
async step(l, fn) {
|
|
653
|
+
setLabel(l);
|
|
654
|
+
try {
|
|
655
|
+
return await fn();
|
|
656
|
+
} catch (e) {
|
|
657
|
+
stopAnim();
|
|
658
|
+
const msg = `${l} failed: ${e?.message || e}`.replace(/^\s+/, "");
|
|
659
|
+
spinner.fail(msg);
|
|
660
|
+
throw e;
|
|
661
|
+
}
|
|
662
|
+
},
|
|
663
|
+
phase(l) {
|
|
664
|
+
setLabel(l);
|
|
665
|
+
},
|
|
666
|
+
stop() {
|
|
667
|
+
stopAnim();
|
|
668
|
+
spinner.stop();
|
|
669
|
+
}
|
|
670
|
+
};
|
|
671
|
+
}
|
|
672
|
+
function renderCommitBlock(opts) {
|
|
673
|
+
const dim = (s) => chalk.dim(s);
|
|
674
|
+
const white = (s) => chalk.white(s);
|
|
675
|
+
const msgColor = opts.messageLabelColor || dim;
|
|
676
|
+
const descColor = opts.descriptionLabelColor || dim;
|
|
677
|
+
const titleColor = opts.titleColor || white;
|
|
678
|
+
const bodyFirst = opts.bodyFirstLineColor || white;
|
|
679
|
+
const bodyRest = opts.bodyLineColor || white;
|
|
680
|
+
if (opts.fancy) {
|
|
681
|
+
const heading = opts.heading ? chalk.hex("#9a4dff").bold(opts.heading) : void 0;
|
|
682
|
+
if (heading) borderLine(heading);
|
|
683
|
+
borderLine(msgColor("Title:") + " " + titleColor(`${opts.indexPrefix || ""}${opts.title}`));
|
|
684
|
+
} else {
|
|
685
|
+
if (opts.heading) borderLine(chalk.bold(opts.heading));
|
|
686
|
+
if (!opts.hideMessageLabel)
|
|
687
|
+
borderLine(msgColor("Message:") + " " + titleColor(`${opts.indexPrefix || ""}${opts.title}`));
|
|
688
|
+
else
|
|
689
|
+
borderLine(msgColor("Title:") + " " + titleColor(`${opts.indexPrefix || ""}${opts.title}`));
|
|
690
|
+
}
|
|
691
|
+
borderLine();
|
|
692
|
+
if (opts.body) {
|
|
693
|
+
const lines = opts.body.split("\n");
|
|
694
|
+
lines.forEach((line, i) => {
|
|
695
|
+
if (line.trim().length === 0) borderLine();
|
|
696
|
+
else if (i === 0) borderLine(descColor("Description:") + " " + bodyFirst(line));
|
|
697
|
+
else borderLine(bodyRest(line));
|
|
698
|
+
});
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
// src/workflow/generate.ts
|
|
550
703
|
async function runGenerate(config) {
|
|
704
|
+
const startedAt = Date.now();
|
|
551
705
|
if (!await ensureStagedChanges()) {
|
|
552
706
|
console.log("No staged changes.");
|
|
553
707
|
return;
|
|
@@ -557,24 +711,18 @@ async function runGenerate(config) {
|
|
|
557
711
|
console.log("No diff content detected after staging. Aborting.");
|
|
558
712
|
return;
|
|
559
713
|
}
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
for (const f of files) console.log(" " + f.file);
|
|
564
|
-
console.log("\u2502");
|
|
565
|
-
const spinner = ora({ text: " Starting", spinner: "dots" }).start();
|
|
566
|
-
function setPhase(label) {
|
|
567
|
-
spinner.text = " " + chalk.bold(label);
|
|
568
|
-
}
|
|
569
|
-
async function runStep(label, fn) {
|
|
570
|
-
setPhase(label);
|
|
571
|
-
try {
|
|
572
|
-
return await fn();
|
|
573
|
-
} catch (e) {
|
|
574
|
-
spinner.fail(`\u25C7 ${label} failed: ${e.message}`);
|
|
575
|
-
throw e;
|
|
576
|
-
}
|
|
714
|
+
if (process.stdout.isTTY) {
|
|
715
|
+
await animateHeaderBase();
|
|
716
|
+
borderLine();
|
|
577
717
|
}
|
|
718
|
+
sectionTitle("Files");
|
|
719
|
+
borderLine(
|
|
720
|
+
chalk2.dim(`Detected ${files.length} staged ${files.length === 1 ? "file" : "files"}:`)
|
|
721
|
+
);
|
|
722
|
+
files.forEach((f) => borderLine("\u2022 " + f.file));
|
|
723
|
+
borderLine();
|
|
724
|
+
const phased = createPhasedSpinner(ora);
|
|
725
|
+
const runStep = (label, fn) => phased.step(label, fn);
|
|
578
726
|
let style;
|
|
579
727
|
let plugins;
|
|
580
728
|
let messages;
|
|
@@ -600,8 +748,9 @@ async function runGenerate(config) {
|
|
|
600
748
|
"Analyzing changes",
|
|
601
749
|
async () => applyTransforms(plan.commits, plugins, { cwd: process.cwd(), env: process.env })
|
|
602
750
|
);
|
|
603
|
-
|
|
604
|
-
|
|
751
|
+
phased.phase("Suggested commit");
|
|
752
|
+
phased.stop();
|
|
753
|
+
sectionTitle("Suggested commit");
|
|
605
754
|
candidates = candidates.map((c) => ({
|
|
606
755
|
...c,
|
|
607
756
|
title: formatCommitTitle(c.title, {
|
|
@@ -610,15 +759,7 @@ async function runGenerate(config) {
|
|
|
610
759
|
})
|
|
611
760
|
}));
|
|
612
761
|
const chosen = candidates[0];
|
|
613
|
-
|
|
614
|
-
if (chosen.body) {
|
|
615
|
-
const indent = " ";
|
|
616
|
-
chosen.body.split("\n").forEach((line) => {
|
|
617
|
-
if (line.trim().length === 0) console.log(indent);
|
|
618
|
-
else console.log(indent + chalk.gray(line));
|
|
619
|
-
});
|
|
620
|
-
}
|
|
621
|
-
console.log("\u2502");
|
|
762
|
+
renderCommitBlock({ title: chosen.title, body: chosen.body });
|
|
622
763
|
const pluginErrors = await runValidations(chosen, plugins, {
|
|
623
764
|
cwd: process.cwd(),
|
|
624
765
|
env: process.env
|
|
@@ -626,18 +767,22 @@ async function runGenerate(config) {
|
|
|
626
767
|
const guardErrors = checkCandidate(chosen);
|
|
627
768
|
const errors = [...pluginErrors, ...guardErrors];
|
|
628
769
|
if (errors.length) {
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
770
|
+
borderLine();
|
|
771
|
+
console.log("\u2299 " + chalk2.bold("Checks"));
|
|
772
|
+
const errorLines = ["Validation issues:", ...errors.map((e) => chalk2.red("\u2022 " + e))];
|
|
773
|
+
errorLines.forEach((l) => borderLine(l));
|
|
632
774
|
}
|
|
775
|
+
borderLine();
|
|
633
776
|
const yn = await selectYesNo();
|
|
634
777
|
if (!yn) {
|
|
635
|
-
|
|
778
|
+
borderLine();
|
|
779
|
+
abortMessage();
|
|
636
780
|
return;
|
|
637
781
|
}
|
|
638
782
|
await createCommit(chosen.title, chosen.body);
|
|
639
783
|
saveSession({ plan, chosen, mode: "single" });
|
|
640
|
-
|
|
784
|
+
borderLine();
|
|
785
|
+
finalSuccess({ count: 1, startedAt });
|
|
641
786
|
}
|
|
642
787
|
function saveSession(data) {
|
|
643
788
|
const dir = ".git/.aicc-cache";
|
|
@@ -649,10 +794,10 @@ async function selectYesNo() {
|
|
|
649
794
|
{
|
|
650
795
|
type: "list",
|
|
651
796
|
name: "choice",
|
|
652
|
-
message: "
|
|
797
|
+
message: "Use the commit?",
|
|
653
798
|
choices: [
|
|
654
|
-
{ name: "
|
|
655
|
-
{ name: "
|
|
799
|
+
{ name: "Yes", value: true },
|
|
800
|
+
{ name: "No", value: false }
|
|
656
801
|
],
|
|
657
802
|
default: 0
|
|
658
803
|
}
|
|
@@ -661,7 +806,8 @@ async function selectYesNo() {
|
|
|
661
806
|
}
|
|
662
807
|
|
|
663
808
|
// src/workflow/split.ts
|
|
664
|
-
import
|
|
809
|
+
import chalk3 from "chalk";
|
|
810
|
+
import ora2 from "ora";
|
|
665
811
|
|
|
666
812
|
// src/cluster.ts
|
|
667
813
|
var topLevel = (file) => file.split("/")[0] || file;
|
|
@@ -707,6 +853,7 @@ import { writeFileSync as writeFileSync2, mkdirSync as mkdirSync2, existsSync as
|
|
|
707
853
|
import { join as join2 } from "path";
|
|
708
854
|
import inquirer2 from "inquirer";
|
|
709
855
|
async function runSplit(config, desired) {
|
|
856
|
+
const startedAt = Date.now();
|
|
710
857
|
if (!await ensureStagedChanges()) {
|
|
711
858
|
console.log("No staged changes.");
|
|
712
859
|
return;
|
|
@@ -716,43 +863,45 @@ async function runSplit(config, desired) {
|
|
|
716
863
|
console.log("No diff content detected after staging. Aborting.");
|
|
717
864
|
return;
|
|
718
865
|
}
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
const history = await getRecentCommitMessages(config.styleSamples);
|
|
723
|
-
const style = buildStyleProfile(history);
|
|
724
|
-
console.log("\u25C7 Loading plugins");
|
|
725
|
-
const plugins = await loadPlugins(config);
|
|
726
|
-
console.log("\u25C7 Building prompt");
|
|
727
|
-
const messages = buildGenerationMessages({
|
|
728
|
-
files,
|
|
729
|
-
style,
|
|
730
|
-
config,
|
|
731
|
-
mode: "split",
|
|
732
|
-
desiredCommits: desired
|
|
733
|
-
});
|
|
734
|
-
const provider = new OpenCodeProvider(config.model);
|
|
735
|
-
console.log("\u25C7 Calling model for split plan");
|
|
736
|
-
let raw;
|
|
737
|
-
try {
|
|
738
|
-
raw = await provider.chat(messages, { maxTokens: config.maxTokens });
|
|
739
|
-
} catch (e) {
|
|
740
|
-
console.log(e.message);
|
|
741
|
-
return;
|
|
742
|
-
}
|
|
743
|
-
console.log("\u25C7 Parsing response");
|
|
744
|
-
let plan;
|
|
745
|
-
try {
|
|
746
|
-
plan = extractJSON(raw);
|
|
747
|
-
} catch (e) {
|
|
748
|
-
console.log("JSON parse error: " + e.message);
|
|
749
|
-
return;
|
|
866
|
+
if (process.stdout.isTTY) {
|
|
867
|
+
await animateHeaderBase();
|
|
868
|
+
borderLine();
|
|
750
869
|
}
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
870
|
+
sectionTitle("Files");
|
|
871
|
+
borderLine(
|
|
872
|
+
chalk3.dim(`Detected ${files.length} staged ${files.length === 1 ? "file" : "files"}:`)
|
|
873
|
+
);
|
|
874
|
+
files.forEach((f) => borderLine("\u2022 " + f.file));
|
|
875
|
+
borderLine();
|
|
876
|
+
const phased = createPhasedSpinner(ora2);
|
|
877
|
+
const runStep = (label, fn) => phased.step(label, fn);
|
|
878
|
+
await runStep("Clustering changes", async () => {
|
|
879
|
+
clusterHunks(files);
|
|
755
880
|
});
|
|
881
|
+
const style = await runStep("Profiling style", async () => {
|
|
882
|
+
const history = await getRecentCommitMessages(config.styleSamples);
|
|
883
|
+
return buildStyleProfile(history);
|
|
884
|
+
});
|
|
885
|
+
const plugins = await runStep("Loading plugins", async () => loadPlugins(config));
|
|
886
|
+
const messages = await runStep(
|
|
887
|
+
"Building prompt",
|
|
888
|
+
async () => buildGenerationMessages({ files, style, config, mode: "split", desiredCommits: desired })
|
|
889
|
+
);
|
|
890
|
+
const provider = new OpenCodeProvider(config.model);
|
|
891
|
+
const raw = await runStep(
|
|
892
|
+
"Calling model",
|
|
893
|
+
async () => provider.chat(messages, { maxTokens: config.maxTokens })
|
|
894
|
+
);
|
|
895
|
+
const plan = await runStep("Parsing response", async () => extractJSON(raw));
|
|
896
|
+
let candidates = await runStep(
|
|
897
|
+
"Analyzing changes",
|
|
898
|
+
async () => applyTransforms(plan.commits, plugins, { cwd: process.cwd(), env: process.env })
|
|
899
|
+
);
|
|
900
|
+
const plural = candidates.length !== 1;
|
|
901
|
+
phased.phase(plural ? "Suggested commits" : "Suggested commit");
|
|
902
|
+
phased.stop();
|
|
903
|
+
sectionTitle(plural ? "Suggested commits" : "Suggested commit");
|
|
904
|
+
borderLine();
|
|
756
905
|
candidates = candidates.map((c) => ({
|
|
757
906
|
...c,
|
|
758
907
|
title: formatCommitTitle(c.title, {
|
|
@@ -760,48 +909,67 @@ async function runSplit(config, desired) {
|
|
|
760
909
|
mode: config.gitmojiMode || "standard"
|
|
761
910
|
})
|
|
762
911
|
}));
|
|
763
|
-
|
|
764
|
-
candidates.forEach((c) => {
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
912
|
+
const fancy = candidates.length > 1;
|
|
913
|
+
candidates.forEach((c, idx) => {
|
|
914
|
+
renderCommitBlock({
|
|
915
|
+
title: c.title,
|
|
916
|
+
body: c.body,
|
|
917
|
+
heading: fancy ? `Commit n\xB0${idx + 1}` : void 0,
|
|
918
|
+
hideMessageLabel: fancy,
|
|
919
|
+
fancy
|
|
920
|
+
});
|
|
921
|
+
if (idx < candidates.length - 1) {
|
|
922
|
+
borderLine();
|
|
923
|
+
borderLine();
|
|
772
924
|
}
|
|
773
925
|
});
|
|
926
|
+
borderLine();
|
|
774
927
|
const { ok } = await inquirer2.prompt([
|
|
775
928
|
{
|
|
776
929
|
type: "list",
|
|
777
930
|
name: "ok",
|
|
778
|
-
message: "
|
|
931
|
+
message: "Use the commits?",
|
|
779
932
|
choices: [
|
|
780
|
-
{ name: "
|
|
781
|
-
{ name: "
|
|
933
|
+
{ name: "Yes", value: true },
|
|
934
|
+
{ name: "No", value: false }
|
|
782
935
|
],
|
|
783
936
|
default: 0
|
|
784
937
|
}
|
|
785
938
|
]);
|
|
786
939
|
if (!ok) {
|
|
787
|
-
|
|
940
|
+
borderLine();
|
|
941
|
+
abortMessage();
|
|
788
942
|
return;
|
|
789
943
|
}
|
|
944
|
+
const allChangedFiles = [...new Set(files.map((f) => f.file))];
|
|
945
|
+
let useFiles = false;
|
|
946
|
+
if (candidates.every((c) => Array.isArray(c.files) && c.files.length > 0)) {
|
|
947
|
+
const flat = candidates.flatMap((c) => c.files);
|
|
948
|
+
const unique = new Set(flat);
|
|
949
|
+
if ([...unique].every((f) => allChangedFiles.includes(f))) {
|
|
950
|
+
useFiles = true;
|
|
951
|
+
}
|
|
952
|
+
}
|
|
953
|
+
if (!useFiles) {
|
|
954
|
+
const buckets = candidates.map(() => []);
|
|
955
|
+
allChangedFiles.forEach((f, i) => buckets[i % buckets.length].push(f));
|
|
956
|
+
candidates = candidates.map((c, i) => ({ ...c, files: buckets[i] }));
|
|
957
|
+
useFiles = true;
|
|
958
|
+
}
|
|
959
|
+
let success = 0;
|
|
790
960
|
for (const candidate of candidates) {
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
errors.forEach((e) => console.log(" -", e));
|
|
800
|
-
continue;
|
|
961
|
+
await resetIndex();
|
|
962
|
+
await stageFiles(candidate.files || []);
|
|
963
|
+
const stagedNow = await getStagedFiles();
|
|
964
|
+
if (!stagedNow.length) continue;
|
|
965
|
+
try {
|
|
966
|
+
await createCommit(candidate.title, candidate.body);
|
|
967
|
+
success++;
|
|
968
|
+
} catch (e) {
|
|
801
969
|
}
|
|
802
|
-
await createCommit(candidate.title, candidate.body);
|
|
803
|
-
console.log(chalk2.green("Committed: ") + candidate.title);
|
|
804
970
|
}
|
|
971
|
+
borderLine();
|
|
972
|
+
finalSuccess({ count: success, startedAt });
|
|
805
973
|
saveSession2({ plan, chosen: candidates, mode: "split" });
|
|
806
974
|
}
|
|
807
975
|
function saveSession2(data) {
|
|
@@ -811,9 +979,10 @@ function saveSession2(data) {
|
|
|
811
979
|
}
|
|
812
980
|
|
|
813
981
|
// src/workflow/refine.ts
|
|
814
|
-
import
|
|
815
|
-
import
|
|
816
|
-
import
|
|
982
|
+
import chalk4 from "chalk";
|
|
983
|
+
import ora3 from "ora";
|
|
984
|
+
import inquirer3 from "inquirer";
|
|
985
|
+
import { readFileSync, existsSync as existsSync3, writeFileSync as writeFileSync3, mkdirSync as mkdirSync3 } from "fs";
|
|
817
986
|
import { join as join3 } from "path";
|
|
818
987
|
|
|
819
988
|
// src/workflow/util.ts
|
|
@@ -839,20 +1008,39 @@ function loadSession() {
|
|
|
839
1008
|
return null;
|
|
840
1009
|
}
|
|
841
1010
|
}
|
|
1011
|
+
function saveSession3(session) {
|
|
1012
|
+
const dir = ".git/.aicc-cache";
|
|
1013
|
+
if (!existsSync3(dir)) mkdirSync3(dir, { recursive: true });
|
|
1014
|
+
writeFileSync3(join3(dir, "last-session.json"), JSON.stringify(session, null, 2));
|
|
1015
|
+
}
|
|
842
1016
|
async function runRefine(config, options) {
|
|
843
|
-
const
|
|
1017
|
+
const startedAt = Date.now();
|
|
844
1018
|
const session = loadSession();
|
|
845
1019
|
if (!session) {
|
|
846
|
-
|
|
1020
|
+
console.log("No previous session found.");
|
|
847
1021
|
return;
|
|
848
1022
|
}
|
|
849
|
-
spinner.succeed("Session loaded");
|
|
850
1023
|
const plan = session.plan;
|
|
851
|
-
const index = options.index
|
|
1024
|
+
const index = options.index ? Number(options.index) - 1 : 0;
|
|
852
1025
|
if (!plan.commits[index]) {
|
|
853
1026
|
console.log("Invalid index.");
|
|
854
1027
|
return;
|
|
855
1028
|
}
|
|
1029
|
+
if (process.stdout.isTTY) {
|
|
1030
|
+
await animateHeaderBase();
|
|
1031
|
+
borderLine();
|
|
1032
|
+
}
|
|
1033
|
+
sectionTitle("Original");
|
|
1034
|
+
const original = plan.commits[index];
|
|
1035
|
+
const originalLines = [chalk4.yellow(original.title)];
|
|
1036
|
+
if (original.body) {
|
|
1037
|
+
original.body.split("\n").forEach((line) => {
|
|
1038
|
+
if (line.trim().length === 0) originalLines.push("");
|
|
1039
|
+
else originalLines.push(chalk4.white(line));
|
|
1040
|
+
});
|
|
1041
|
+
}
|
|
1042
|
+
originalLines.forEach((l) => l.trim().length === 0 ? borderLine() : borderLine(l));
|
|
1043
|
+
borderLine();
|
|
856
1044
|
const instructions = [];
|
|
857
1045
|
if (options.shorter) instructions.push("Make the title shorter but keep meaning.");
|
|
858
1046
|
if (options.longer) instructions.push("Add more specificity to the title.");
|
|
@@ -866,43 +1054,52 @@ async function runRefine(config, options) {
|
|
|
866
1054
|
return;
|
|
867
1055
|
}
|
|
868
1056
|
}
|
|
1057
|
+
const phased = createPhasedSpinner(ora3);
|
|
1058
|
+
const runStep = (label, fn) => phased.step(label, fn);
|
|
869
1059
|
const provider = new OpenCodeProvider(config.model);
|
|
870
|
-
const messages =
|
|
871
|
-
|
|
872
|
-
index,
|
|
873
|
-
|
|
874
|
-
|
|
1060
|
+
const messages = await runStep(
|
|
1061
|
+
"Building prompt",
|
|
1062
|
+
async () => buildRefineMessages({ originalPlan: plan, index, instructions, config })
|
|
1063
|
+
);
|
|
1064
|
+
const raw = await runStep(
|
|
1065
|
+
"Calling model",
|
|
1066
|
+
async () => provider.chat(messages, { maxTokens: config.maxTokens })
|
|
1067
|
+
);
|
|
1068
|
+
const refinedPlan = await runStep("Parsing response", async () => extractJSON(raw));
|
|
1069
|
+
refinedPlan.commits[0].title = formatCommitTitle(refinedPlan.commits[0].title, {
|
|
1070
|
+
allowGitmoji: !!config.gitmoji || !!options.emoji,
|
|
1071
|
+
mode: config.gitmojiMode || "standard"
|
|
875
1072
|
});
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
1073
|
+
phased.phase("Suggested commit");
|
|
1074
|
+
phased.stop();
|
|
1075
|
+
sectionTitle("Suggested commit");
|
|
1076
|
+
renderCommitBlock({
|
|
1077
|
+
title: refinedPlan.commits[0].title,
|
|
1078
|
+
body: refinedPlan.commits[0].body,
|
|
1079
|
+
titleColor: (s) => chalk4.yellow(s)
|
|
1080
|
+
});
|
|
1081
|
+
borderLine();
|
|
1082
|
+
const { ok } = await inquirer3.prompt([
|
|
1083
|
+
{
|
|
1084
|
+
type: "list",
|
|
1085
|
+
name: "ok",
|
|
1086
|
+
message: "Use the commit?",
|
|
1087
|
+
choices: [
|
|
1088
|
+
{ name: "Yes", value: true },
|
|
1089
|
+
{ name: "No", value: false }
|
|
1090
|
+
],
|
|
1091
|
+
default: 0
|
|
1092
|
+
}
|
|
1093
|
+
]);
|
|
1094
|
+
if (!ok) {
|
|
1095
|
+
borderLine();
|
|
1096
|
+
abortMessage();
|
|
882
1097
|
return;
|
|
883
1098
|
}
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
});
|
|
889
|
-
}
|
|
890
|
-
console.log(chalk3.cyan("\nRefined candidate:"));
|
|
891
|
-
console.log(chalk3.yellow(refined.commits[0].title));
|
|
892
|
-
if (refined.commits[0].body) {
|
|
893
|
-
const indent = " ";
|
|
894
|
-
refined.commits[0].body.split("\n").forEach((line) => {
|
|
895
|
-
if (line.trim().length === 0) console.log(indent);
|
|
896
|
-
else console.log(indent + chalk3.gray(line));
|
|
897
|
-
});
|
|
898
|
-
}
|
|
899
|
-
const accept = await prompt("Accept refined version? (Y/n) ", "y");
|
|
900
|
-
if (!/^n/i.test(accept)) {
|
|
901
|
-
plan.commits[index] = refined.commits[0];
|
|
902
|
-
console.log(chalk3.green("Refinement stored (not retro-committed)."));
|
|
903
|
-
} else {
|
|
904
|
-
console.log("Refinement discarded.");
|
|
905
|
-
}
|
|
1099
|
+
session.plan.commits[index] = refinedPlan.commits[0];
|
|
1100
|
+
saveSession3(session);
|
|
1101
|
+
borderLine();
|
|
1102
|
+
finalSuccess({ count: 1, startedAt });
|
|
906
1103
|
}
|
|
907
1104
|
|
|
908
1105
|
// src/config.ts
|
|
@@ -918,8 +1115,7 @@ var DEFAULTS = {
|
|
|
918
1115
|
maxTokens: parseInt(process.env.AICC_MAX_TOKENS || "512", 10),
|
|
919
1116
|
cacheDir: ".git/.aicc-cache",
|
|
920
1117
|
plugins: [],
|
|
921
|
-
verbose: process.env.AICC_VERBOSE === "true"
|
|
922
|
-
reasoning: process.env.AICC_REASONING || "low"
|
|
1118
|
+
verbose: process.env.AICC_VERBOSE === "true"
|
|
923
1119
|
};
|
|
924
1120
|
async function loadConfig(cwd = process.cwd()) {
|
|
925
1121
|
const explorer = cosmiconfig("aicc");
|
|
@@ -935,53 +1131,160 @@ async function loadConfig(cwd = process.cwd()) {
|
|
|
935
1131
|
return cfg;
|
|
936
1132
|
}
|
|
937
1133
|
|
|
1134
|
+
// package.json
|
|
1135
|
+
var package_default = {
|
|
1136
|
+
name: "@kud/ai-conventional-commit-cli",
|
|
1137
|
+
version: "0.4.0",
|
|
1138
|
+
type: "module",
|
|
1139
|
+
description: "Opinionated, style-aware AI assistant for crafting and splitting git commits (opencode-based, provider-agnostic).",
|
|
1140
|
+
bin: {
|
|
1141
|
+
"ai-conventional-commit": "dist/index.js"
|
|
1142
|
+
},
|
|
1143
|
+
files: [
|
|
1144
|
+
"dist",
|
|
1145
|
+
"README.md",
|
|
1146
|
+
"LICENSE"
|
|
1147
|
+
],
|
|
1148
|
+
scripts: {
|
|
1149
|
+
dev: "tsx src/index.ts",
|
|
1150
|
+
build: "tsup src/index.ts --format esm --dts",
|
|
1151
|
+
prepublishOnly: "npm run build",
|
|
1152
|
+
lint: "eslint .",
|
|
1153
|
+
format: "prettier --write .",
|
|
1154
|
+
test: "vitest run",
|
|
1155
|
+
"test:watch": "vitest",
|
|
1156
|
+
commit: "cz"
|
|
1157
|
+
},
|
|
1158
|
+
dependencies: {
|
|
1159
|
+
"@commitlint/config-conventional": "^19.8.1",
|
|
1160
|
+
chalk: "^5.6.0",
|
|
1161
|
+
clipanion: "^3.2.1",
|
|
1162
|
+
cosmiconfig: "^9.0.0",
|
|
1163
|
+
execa: "^9.6.0",
|
|
1164
|
+
"fast-glob": "^3.3.3",
|
|
1165
|
+
inquirer: "^9.3.7",
|
|
1166
|
+
keyv: "^5.5.0",
|
|
1167
|
+
"lru-cache": "^11.2.1",
|
|
1168
|
+
ora: "^8.2.0",
|
|
1169
|
+
pathe: "^2.0.3",
|
|
1170
|
+
"simple-git": "^3.28.0",
|
|
1171
|
+
"strip-ansi": "^7.1.0",
|
|
1172
|
+
zod: "^4.1.5"
|
|
1173
|
+
},
|
|
1174
|
+
devDependencies: {
|
|
1175
|
+
"@types/inquirer": "^9.0.7",
|
|
1176
|
+
"@types/node": "^24.3.1",
|
|
1177
|
+
"@typescript-eslint/eslint-plugin": "^8.42.0",
|
|
1178
|
+
"@typescript-eslint/parser": "^8.42.0",
|
|
1179
|
+
"cz-conventional-changelog": "^3.3.0",
|
|
1180
|
+
eslint: "^9.35.0",
|
|
1181
|
+
prettier: "^3.6.2",
|
|
1182
|
+
tsup: "^8.5.0",
|
|
1183
|
+
tsx: "^4.20.5",
|
|
1184
|
+
typescript: "^5.9.2",
|
|
1185
|
+
vitest: "^3.2.4"
|
|
1186
|
+
},
|
|
1187
|
+
config: {
|
|
1188
|
+
commitizen: {
|
|
1189
|
+
path: "cz-conventional-changelog"
|
|
1190
|
+
}
|
|
1191
|
+
},
|
|
1192
|
+
engines: {
|
|
1193
|
+
node: ">=18.17"
|
|
1194
|
+
},
|
|
1195
|
+
license: "MIT"
|
|
1196
|
+
};
|
|
1197
|
+
|
|
938
1198
|
// src/index.ts
|
|
1199
|
+
var pkgVersion = package_default.version || "0.0.0";
|
|
939
1200
|
var GenerateCommand = class extends Command {
|
|
940
|
-
static paths = [[`generate`], [
|
|
1201
|
+
static paths = [[`generate`], []];
|
|
1202
|
+
static usage = Command.Usage({
|
|
1203
|
+
description: "Generate a conventional commit message for staged changes.",
|
|
1204
|
+
details: `Generates a single commit message using AI with style + guardrails.
|
|
1205
|
+
Add --gitmoji[-pure] to enable emoji styles.`,
|
|
1206
|
+
examples: [
|
|
1207
|
+
["Generate a commit with gitmoji style", "ai-conventional-commit generate --gitmoji"]
|
|
1208
|
+
]
|
|
1209
|
+
});
|
|
941
1210
|
gitmoji = Option.Boolean("--gitmoji", false, {
|
|
942
|
-
description: "Gitmoji mode: emoji
|
|
1211
|
+
description: "Gitmoji mode (vs --gitmoji-pure): emoji + type (emoji: subject)"
|
|
943
1212
|
});
|
|
944
1213
|
gitmojiPure = Option.Boolean("--gitmoji-pure", false, {
|
|
945
|
-
description: "Pure gitmoji mode: emoji
|
|
1214
|
+
description: "Pure gitmoji mode (vs --gitmoji): emoji only (emoji: subject)"
|
|
1215
|
+
});
|
|
1216
|
+
model = Option.String("-m,--model", {
|
|
1217
|
+
required: false,
|
|
1218
|
+
description: "Model provider/name (e.g. github-copilot/gpt-5)"
|
|
946
1219
|
});
|
|
947
|
-
reasoning = Option.String("--reasoning", { required: false });
|
|
948
1220
|
async execute() {
|
|
949
1221
|
const config = await loadConfig();
|
|
950
1222
|
if (this.gitmoji || this.gitmojiPure) {
|
|
951
1223
|
config.gitmoji = true;
|
|
952
1224
|
config.gitmojiMode = this.gitmojiPure ? "gitmoji-pure" : "gitmoji";
|
|
953
1225
|
}
|
|
954
|
-
if (this.
|
|
1226
|
+
if (this.model) config.model = this.model;
|
|
955
1227
|
await runGenerate(config);
|
|
956
1228
|
}
|
|
957
1229
|
};
|
|
958
1230
|
var SplitCommand = class extends Command {
|
|
959
1231
|
static paths = [[`split`]];
|
|
1232
|
+
static usage = Command.Usage({
|
|
1233
|
+
description: "Propose multiple smaller conventional commits for current staged diff.",
|
|
1234
|
+
details: `Analyzes staged changes, groups them logically and suggests multiple commit messages.
|
|
1235
|
+
Use --max to limit the number of proposals.`,
|
|
1236
|
+
examples: [
|
|
1237
|
+
[
|
|
1238
|
+
"Split into at most 3 commits with gitmoji",
|
|
1239
|
+
"ai-conventional-commit split --max 3 --gitmoji"
|
|
1240
|
+
]
|
|
1241
|
+
]
|
|
1242
|
+
});
|
|
960
1243
|
max = Option.String("--max", { description: "Max proposed commits", required: false });
|
|
961
|
-
gitmoji = Option.Boolean("--gitmoji", false
|
|
962
|
-
|
|
963
|
-
|
|
1244
|
+
gitmoji = Option.Boolean("--gitmoji", false, {
|
|
1245
|
+
description: "Gitmoji mode (vs --gitmoji-pure): emoji + type"
|
|
1246
|
+
});
|
|
1247
|
+
gitmojiPure = Option.Boolean("--gitmoji-pure", false, {
|
|
1248
|
+
description: "Pure gitmoji mode (vs --gitmoji): emoji only"
|
|
1249
|
+
});
|
|
1250
|
+
model = Option.String("-m,--model", {
|
|
1251
|
+
required: false,
|
|
1252
|
+
description: "Model provider/name override"
|
|
1253
|
+
});
|
|
964
1254
|
async execute() {
|
|
965
1255
|
const config = await loadConfig();
|
|
966
1256
|
if (this.gitmoji || this.gitmojiPure) {
|
|
967
1257
|
config.gitmoji = true;
|
|
968
1258
|
config.gitmojiMode = this.gitmojiPure ? "gitmoji-pure" : "gitmoji";
|
|
969
1259
|
}
|
|
970
|
-
if (this.
|
|
1260
|
+
if (this.model) config.model = this.model;
|
|
971
1261
|
await runSplit(config, this.max ? parseInt(this.max, 10) : void 0);
|
|
972
1262
|
}
|
|
973
1263
|
};
|
|
974
1264
|
var RefineCommand = class extends Command {
|
|
975
1265
|
static paths = [[`refine`]];
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
1266
|
+
static usage = Command.Usage({
|
|
1267
|
+
description: "Refine the last (or chosen) commit message with style rules.",
|
|
1268
|
+
details: `Allows targeted improvements: shorter/longer length, inject scope, add emoji, or select a specific index when multiple commits were generated earlier.`,
|
|
1269
|
+
examples: [
|
|
1270
|
+
["Shorten the last commit message", "ai-conventional-commit refine --shorter"],
|
|
1271
|
+
["Add a scope to the last commit", "ai-conventional-commit refine --scope ui"]
|
|
1272
|
+
]
|
|
1273
|
+
});
|
|
1274
|
+
shorter = Option.Boolean("--shorter", false, { description: "Make message more concise" });
|
|
1275
|
+
longer = Option.Boolean("--longer", false, { description: "Expand message with detail" });
|
|
1276
|
+
scope = Option.String("--scope", { description: "Override/add scope (e.g. ui, api)" });
|
|
1277
|
+
emoji = Option.Boolean("--emoji", false, { description: "Add appropriate gitmoji (non-pure)" });
|
|
1278
|
+
index = Option.String("--index", {
|
|
1279
|
+
description: "Select commit index if multiple were generated"
|
|
1280
|
+
});
|
|
1281
|
+
model = Option.String("-m,--model", {
|
|
1282
|
+
required: false,
|
|
1283
|
+
description: "Model provider/name override"
|
|
1284
|
+
});
|
|
982
1285
|
async execute() {
|
|
983
1286
|
const config = await loadConfig();
|
|
984
|
-
if (this.
|
|
1287
|
+
if (this.model) config.model = this.model;
|
|
985
1288
|
await runRefine(config, {
|
|
986
1289
|
shorter: this.shorter,
|
|
987
1290
|
longer: this.longer,
|
|
@@ -991,14 +1294,57 @@ var RefineCommand = class extends Command {
|
|
|
991
1294
|
});
|
|
992
1295
|
}
|
|
993
1296
|
};
|
|
1297
|
+
var HelpCommand = class extends Command {
|
|
1298
|
+
static paths = [[`--help`], [`-h`]];
|
|
1299
|
+
// capture explicit help
|
|
1300
|
+
async execute() {
|
|
1301
|
+
this.context.stdout.write(globalHelp() + "\n");
|
|
1302
|
+
}
|
|
1303
|
+
};
|
|
1304
|
+
function globalHelp() {
|
|
1305
|
+
return `ai-conventional-commit v${pkgVersion}
|
|
1306
|
+
|
|
1307
|
+
Usage:
|
|
1308
|
+
ai-conventional-commit [generate] [options] Generate a commit (default)
|
|
1309
|
+
ai-conventional-commit split [options] Propose multiple commits
|
|
1310
|
+
ai-conventional-commit refine [options] Refine last or indexed commit
|
|
1311
|
+
|
|
1312
|
+
Global Options:
|
|
1313
|
+
-m, --model <provider/name> Override model provider/name
|
|
1314
|
+
--gitmoji[-pure] Gitmoji modes: emoji + type (default) or pure emoji only
|
|
1315
|
+
-h, --help Show this help
|
|
1316
|
+
-V, --version Show version
|
|
1317
|
+
|
|
1318
|
+
Refine Options:
|
|
1319
|
+
--shorter / --longer Adjust message length
|
|
1320
|
+
--scope <scope> Add or replace scope
|
|
1321
|
+
--emoji Add suitable gitmoji
|
|
1322
|
+
--index <n> Select commit index
|
|
1323
|
+
|
|
1324
|
+
Examples:
|
|
1325
|
+
ai-conventional-commit --gitmoji
|
|
1326
|
+
ai-conventional-commit --gitmoji-pure
|
|
1327
|
+
ai-conventional-commit split --max 3 --gitmoji
|
|
1328
|
+
ai-conventional-commit refine --scope api --emoji
|
|
1329
|
+
`;
|
|
1330
|
+
}
|
|
1331
|
+
var VersionCommand = class extends Command {
|
|
1332
|
+
static paths = [[`--version`], [`-V`]];
|
|
1333
|
+
async execute() {
|
|
1334
|
+
this.context.stdout.write(`${pkgVersion}
|
|
1335
|
+
`);
|
|
1336
|
+
}
|
|
1337
|
+
};
|
|
994
1338
|
var cli = new Cli({
|
|
995
1339
|
binaryLabel: "ai-conventional-commit",
|
|
996
1340
|
binaryName: "ai-conventional-commit",
|
|
997
|
-
binaryVersion:
|
|
1341
|
+
binaryVersion: pkgVersion
|
|
998
1342
|
});
|
|
999
1343
|
cli.register(GenerateCommand);
|
|
1000
1344
|
cli.register(SplitCommand);
|
|
1001
1345
|
cli.register(RefineCommand);
|
|
1346
|
+
cli.register(HelpCommand);
|
|
1347
|
+
cli.register(VersionCommand);
|
|
1002
1348
|
cli.runExit(process.argv.slice(2), {
|
|
1003
1349
|
stdin: process.stdin,
|
|
1004
1350
|
stdout: process.stdout,
|