@kud/ai-conventional-commit-cli 0.3.0 → 0.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +55 -17
- package/dist/index.cjs +197 -55
- package/dist/index.js +493 -168
- package/package.json +12 -4
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,11 +559,13 @@ var formatCommitTitle = (raw, opts) => {
|
|
|
547
559
|
import { writeFileSync, mkdirSync, existsSync } from "fs";
|
|
548
560
|
import { join } from "path";
|
|
549
561
|
import inquirer from "inquirer";
|
|
550
|
-
|
|
551
|
-
|
|
562
|
+
|
|
563
|
+
// src/workflow/ui.ts
|
|
564
|
+
import chalk from "chalk";
|
|
565
|
+
function animateHeaderBase(text = "ai-conventional-commit") {
|
|
552
566
|
if (!process.stdout.isTTY || process.env.AICC_NO_ANIMATION) {
|
|
553
|
-
console.log("\n\u250C
|
|
554
|
-
return;
|
|
567
|
+
console.log("\n\u250C " + chalk.bold(text));
|
|
568
|
+
return Promise.resolve();
|
|
555
569
|
}
|
|
556
570
|
const palette = [
|
|
557
571
|
"#3a0d6d",
|
|
@@ -566,14 +580,128 @@ async function animateHeader() {
|
|
|
566
580
|
"#5a1ea3"
|
|
567
581
|
];
|
|
568
582
|
process.stdout.write("\n");
|
|
569
|
-
|
|
583
|
+
return palette.reduce(async (p, color) => {
|
|
584
|
+
await p;
|
|
570
585
|
const frame = chalk.bold.hex(color)(text);
|
|
571
|
-
process.stdout.write("\r\u250C
|
|
586
|
+
process.stdout.write("\r\u250C " + frame);
|
|
572
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
|
+
});
|
|
573
699
|
}
|
|
574
|
-
process.stdout.write("\n");
|
|
575
700
|
}
|
|
701
|
+
|
|
702
|
+
// src/workflow/generate.ts
|
|
576
703
|
async function runGenerate(config) {
|
|
704
|
+
const startedAt = Date.now();
|
|
577
705
|
if (!await ensureStagedChanges()) {
|
|
578
706
|
console.log("No staged changes.");
|
|
579
707
|
return;
|
|
@@ -583,26 +711,18 @@ async function runGenerate(config) {
|
|
|
583
711
|
console.log("No diff content detected after staging. Aborting.");
|
|
584
712
|
return;
|
|
585
713
|
}
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
`\u25C6 ${chalk.bold(`Detected ${files.length} staged ${files.length === 1 ? "file" : "files"}:`)}`
|
|
590
|
-
);
|
|
591
|
-
for (const f of files) console.log(" \u2022 " + f.file);
|
|
592
|
-
console.log("\u2502");
|
|
593
|
-
const spinner = ora({ text: " Starting", spinner: "dots" }).start();
|
|
594
|
-
function setPhase(label) {
|
|
595
|
-
spinner.text = " " + chalk.bold(label);
|
|
596
|
-
}
|
|
597
|
-
async function runStep(label, fn) {
|
|
598
|
-
setPhase(label);
|
|
599
|
-
try {
|
|
600
|
-
return await fn();
|
|
601
|
-
} catch (e) {
|
|
602
|
-
spinner.fail(`\u25C7 ${label} failed: ${e.message}`);
|
|
603
|
-
throw e;
|
|
604
|
-
}
|
|
714
|
+
if (process.stdout.isTTY) {
|
|
715
|
+
await animateHeaderBase();
|
|
716
|
+
borderLine();
|
|
605
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);
|
|
606
726
|
let style;
|
|
607
727
|
let plugins;
|
|
608
728
|
let messages;
|
|
@@ -628,8 +748,9 @@ async function runGenerate(config) {
|
|
|
628
748
|
"Analyzing changes",
|
|
629
749
|
async () => applyTransforms(plan.commits, plugins, { cwd: process.cwd(), env: process.env })
|
|
630
750
|
);
|
|
631
|
-
|
|
632
|
-
|
|
751
|
+
phased.phase("Suggested commit");
|
|
752
|
+
phased.stop();
|
|
753
|
+
sectionTitle("Suggested commit");
|
|
633
754
|
candidates = candidates.map((c) => ({
|
|
634
755
|
...c,
|
|
635
756
|
title: formatCommitTitle(c.title, {
|
|
@@ -638,16 +759,7 @@ async function runGenerate(config) {
|
|
|
638
759
|
})
|
|
639
760
|
}));
|
|
640
761
|
const chosen = candidates[0];
|
|
641
|
-
|
|
642
|
-
if (chosen.body) {
|
|
643
|
-
const indent = " ";
|
|
644
|
-
console.log(indent);
|
|
645
|
-
chosen.body.split("\n").forEach((line) => {
|
|
646
|
-
if (line.trim().length === 0) console.log(indent);
|
|
647
|
-
else console.log(indent + chalk.white(line));
|
|
648
|
-
});
|
|
649
|
-
}
|
|
650
|
-
console.log("\u2502");
|
|
762
|
+
renderCommitBlock({ title: chosen.title, body: chosen.body });
|
|
651
763
|
const pluginErrors = await runValidations(chosen, plugins, {
|
|
652
764
|
cwd: process.cwd(),
|
|
653
765
|
env: process.env
|
|
@@ -655,18 +767,22 @@ async function runGenerate(config) {
|
|
|
655
767
|
const guardErrors = checkCandidate(chosen);
|
|
656
768
|
const errors = [...pluginErrors, ...guardErrors];
|
|
657
769
|
if (errors.length) {
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
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));
|
|
661
774
|
}
|
|
775
|
+
borderLine();
|
|
662
776
|
const yn = await selectYesNo();
|
|
663
777
|
if (!yn) {
|
|
664
|
-
|
|
778
|
+
borderLine();
|
|
779
|
+
abortMessage();
|
|
665
780
|
return;
|
|
666
781
|
}
|
|
667
782
|
await createCommit(chosen.title, chosen.body);
|
|
668
783
|
saveSession({ plan, chosen, mode: "single" });
|
|
669
|
-
|
|
784
|
+
borderLine();
|
|
785
|
+
finalSuccess({ count: 1, startedAt });
|
|
670
786
|
}
|
|
671
787
|
function saveSession(data) {
|
|
672
788
|
const dir = ".git/.aicc-cache";
|
|
@@ -678,7 +794,7 @@ async function selectYesNo() {
|
|
|
678
794
|
{
|
|
679
795
|
type: "list",
|
|
680
796
|
name: "choice",
|
|
681
|
-
message: "
|
|
797
|
+
message: "Use the commit?",
|
|
682
798
|
choices: [
|
|
683
799
|
{ name: "Yes", value: true },
|
|
684
800
|
{ name: "No", value: false }
|
|
@@ -690,7 +806,8 @@ async function selectYesNo() {
|
|
|
690
806
|
}
|
|
691
807
|
|
|
692
808
|
// src/workflow/split.ts
|
|
693
|
-
import
|
|
809
|
+
import chalk3 from "chalk";
|
|
810
|
+
import ora2 from "ora";
|
|
694
811
|
|
|
695
812
|
// src/cluster.ts
|
|
696
813
|
var topLevel = (file) => file.split("/")[0] || file;
|
|
@@ -736,6 +853,7 @@ import { writeFileSync as writeFileSync2, mkdirSync as mkdirSync2, existsSync as
|
|
|
736
853
|
import { join as join2 } from "path";
|
|
737
854
|
import inquirer2 from "inquirer";
|
|
738
855
|
async function runSplit(config, desired) {
|
|
856
|
+
const startedAt = Date.now();
|
|
739
857
|
if (!await ensureStagedChanges()) {
|
|
740
858
|
console.log("No staged changes.");
|
|
741
859
|
return;
|
|
@@ -745,43 +863,45 @@ async function runSplit(config, desired) {
|
|
|
745
863
|
console.log("No diff content detected after staging. Aborting.");
|
|
746
864
|
return;
|
|
747
865
|
}
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
const history = await getRecentCommitMessages(config.styleSamples);
|
|
752
|
-
const style = buildStyleProfile(history);
|
|
753
|
-
console.log("\u25C7 Loading plugins");
|
|
754
|
-
const plugins = await loadPlugins(config);
|
|
755
|
-
console.log("\u25C7 Building prompt");
|
|
756
|
-
const messages = buildGenerationMessages({
|
|
757
|
-
files,
|
|
758
|
-
style,
|
|
759
|
-
config,
|
|
760
|
-
mode: "split",
|
|
761
|
-
desiredCommits: desired
|
|
762
|
-
});
|
|
763
|
-
const provider = new OpenCodeProvider(config.model);
|
|
764
|
-
console.log("\u25C7 Calling model for split plan");
|
|
765
|
-
let raw;
|
|
766
|
-
try {
|
|
767
|
-
raw = await provider.chat(messages, { maxTokens: config.maxTokens });
|
|
768
|
-
} catch (e) {
|
|
769
|
-
console.log(e.message);
|
|
770
|
-
return;
|
|
866
|
+
if (process.stdout.isTTY) {
|
|
867
|
+
await animateHeaderBase();
|
|
868
|
+
borderLine();
|
|
771
869
|
}
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
cwd: process.cwd(),
|
|
783
|
-
env: process.env
|
|
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);
|
|
784
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();
|
|
785
905
|
candidates = candidates.map((c) => ({
|
|
786
906
|
...c,
|
|
787
907
|
title: formatCommitTitle(c.title, {
|
|
@@ -789,48 +909,67 @@ async function runSplit(config, desired) {
|
|
|
789
909
|
mode: config.gitmojiMode || "standard"
|
|
790
910
|
})
|
|
791
911
|
}));
|
|
792
|
-
|
|
793
|
-
candidates.forEach((c) => {
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
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();
|
|
801
924
|
}
|
|
802
925
|
});
|
|
926
|
+
borderLine();
|
|
803
927
|
const { ok } = await inquirer2.prompt([
|
|
804
928
|
{
|
|
805
929
|
type: "list",
|
|
806
930
|
name: "ok",
|
|
807
|
-
message: "
|
|
931
|
+
message: "Use the commits?",
|
|
808
932
|
choices: [
|
|
809
|
-
{ name: "
|
|
810
|
-
{ name: "
|
|
933
|
+
{ name: "Yes", value: true },
|
|
934
|
+
{ name: "No", value: false }
|
|
811
935
|
],
|
|
812
936
|
default: 0
|
|
813
937
|
}
|
|
814
938
|
]);
|
|
815
939
|
if (!ok) {
|
|
816
|
-
|
|
940
|
+
borderLine();
|
|
941
|
+
abortMessage();
|
|
817
942
|
return;
|
|
818
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;
|
|
819
960
|
for (const candidate of candidates) {
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
errors.forEach((e) => console.log(" -", e));
|
|
829
|
-
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) {
|
|
830
969
|
}
|
|
831
|
-
await createCommit(candidate.title, candidate.body);
|
|
832
|
-
console.log(chalk2.green("Committed: ") + candidate.title);
|
|
833
970
|
}
|
|
971
|
+
borderLine();
|
|
972
|
+
finalSuccess({ count: success, startedAt });
|
|
834
973
|
saveSession2({ plan, chosen: candidates, mode: "split" });
|
|
835
974
|
}
|
|
836
975
|
function saveSession2(data) {
|
|
@@ -840,9 +979,10 @@ function saveSession2(data) {
|
|
|
840
979
|
}
|
|
841
980
|
|
|
842
981
|
// src/workflow/refine.ts
|
|
843
|
-
import
|
|
844
|
-
import
|
|
845
|
-
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";
|
|
846
986
|
import { join as join3 } from "path";
|
|
847
987
|
|
|
848
988
|
// src/workflow/util.ts
|
|
@@ -868,20 +1008,39 @@ function loadSession() {
|
|
|
868
1008
|
return null;
|
|
869
1009
|
}
|
|
870
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
|
+
}
|
|
871
1016
|
async function runRefine(config, options) {
|
|
872
|
-
const
|
|
1017
|
+
const startedAt = Date.now();
|
|
873
1018
|
const session = loadSession();
|
|
874
1019
|
if (!session) {
|
|
875
|
-
|
|
1020
|
+
console.log("No previous session found.");
|
|
876
1021
|
return;
|
|
877
1022
|
}
|
|
878
|
-
spinner.succeed("Session loaded");
|
|
879
1023
|
const plan = session.plan;
|
|
880
|
-
const index = options.index
|
|
1024
|
+
const index = options.index ? Number(options.index) - 1 : 0;
|
|
881
1025
|
if (!plan.commits[index]) {
|
|
882
1026
|
console.log("Invalid index.");
|
|
883
1027
|
return;
|
|
884
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();
|
|
885
1044
|
const instructions = [];
|
|
886
1045
|
if (options.shorter) instructions.push("Make the title shorter but keep meaning.");
|
|
887
1046
|
if (options.longer) instructions.push("Add more specificity to the title.");
|
|
@@ -895,43 +1054,52 @@ async function runRefine(config, options) {
|
|
|
895
1054
|
return;
|
|
896
1055
|
}
|
|
897
1056
|
}
|
|
1057
|
+
const phased = createPhasedSpinner(ora3);
|
|
1058
|
+
const runStep = (label, fn) => phased.step(label, fn);
|
|
898
1059
|
const provider = new OpenCodeProvider(config.model);
|
|
899
|
-
const messages =
|
|
900
|
-
|
|
901
|
-
index,
|
|
902
|
-
|
|
903
|
-
|
|
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"
|
|
904
1072
|
});
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
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();
|
|
911
1097
|
return;
|
|
912
1098
|
}
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
});
|
|
918
|
-
}
|
|
919
|
-
console.log(chalk3.cyan("\nRefined candidate:"));
|
|
920
|
-
console.log(chalk3.yellow(refined.commits[0].title));
|
|
921
|
-
if (refined.commits[0].body) {
|
|
922
|
-
const indent = " ";
|
|
923
|
-
refined.commits[0].body.split("\n").forEach((line) => {
|
|
924
|
-
if (line.trim().length === 0) console.log(indent);
|
|
925
|
-
else console.log(indent + chalk3.gray(line));
|
|
926
|
-
});
|
|
927
|
-
}
|
|
928
|
-
const accept = await prompt("Accept refined version? (Y/n) ", "y");
|
|
929
|
-
if (!/^n/i.test(accept)) {
|
|
930
|
-
plan.commits[index] = refined.commits[0];
|
|
931
|
-
console.log(chalk3.green("Refinement stored (not retro-committed)."));
|
|
932
|
-
} else {
|
|
933
|
-
console.log("Refinement discarded.");
|
|
934
|
-
}
|
|
1099
|
+
session.plan.commits[index] = refinedPlan.commits[0];
|
|
1100
|
+
saveSession3(session);
|
|
1101
|
+
borderLine();
|
|
1102
|
+
finalSuccess({ count: 1, startedAt });
|
|
935
1103
|
}
|
|
936
1104
|
|
|
937
1105
|
// src/config.ts
|
|
@@ -947,8 +1115,7 @@ var DEFAULTS = {
|
|
|
947
1115
|
maxTokens: parseInt(process.env.AICC_MAX_TOKENS || "512", 10),
|
|
948
1116
|
cacheDir: ".git/.aicc-cache",
|
|
949
1117
|
plugins: [],
|
|
950
|
-
verbose: process.env.AICC_VERBOSE === "true"
|
|
951
|
-
reasoning: process.env.AICC_REASONING || "low"
|
|
1118
|
+
verbose: process.env.AICC_VERBOSE === "true"
|
|
952
1119
|
};
|
|
953
1120
|
async function loadConfig(cwd = process.cwd()) {
|
|
954
1121
|
const explorer = cosmiconfig("aicc");
|
|
@@ -964,53 +1131,168 @@ async function loadConfig(cwd = process.cwd()) {
|
|
|
964
1131
|
return cfg;
|
|
965
1132
|
}
|
|
966
1133
|
|
|
1134
|
+
// package.json
|
|
1135
|
+
var package_default = {
|
|
1136
|
+
name: "@kud/ai-conventional-commit-cli",
|
|
1137
|
+
version: "0.4.1",
|
|
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
|
+
repository: {
|
|
1197
|
+
type: "git",
|
|
1198
|
+
url: "git+https://github.com/kud/ai-conventional-commit-cli.git"
|
|
1199
|
+
},
|
|
1200
|
+
bugs: {
|
|
1201
|
+
url: "https://github.com/kud/ai-conventional-commit-cli/issues"
|
|
1202
|
+
},
|
|
1203
|
+
homepage: "https://github.com/kud/ai-conventional-commit-cli#readme"
|
|
1204
|
+
};
|
|
1205
|
+
|
|
967
1206
|
// src/index.ts
|
|
1207
|
+
var pkgVersion = package_default.version || "0.0.0";
|
|
968
1208
|
var GenerateCommand = class extends Command {
|
|
969
|
-
static paths = [[`generate`], [
|
|
1209
|
+
static paths = [[`generate`], []];
|
|
1210
|
+
static usage = Command.Usage({
|
|
1211
|
+
description: "Generate a conventional commit message for staged changes.",
|
|
1212
|
+
details: `Generates a single commit message using AI with style + guardrails.
|
|
1213
|
+
Add --gitmoji[-pure] to enable emoji styles.`,
|
|
1214
|
+
examples: [
|
|
1215
|
+
["Generate a commit with gitmoji style", "ai-conventional-commit generate --gitmoji"]
|
|
1216
|
+
]
|
|
1217
|
+
});
|
|
970
1218
|
gitmoji = Option.Boolean("--gitmoji", false, {
|
|
971
|
-
description: "Gitmoji mode: emoji
|
|
1219
|
+
description: "Gitmoji mode (vs --gitmoji-pure): emoji + type (emoji: subject)"
|
|
972
1220
|
});
|
|
973
1221
|
gitmojiPure = Option.Boolean("--gitmoji-pure", false, {
|
|
974
|
-
description: "Pure gitmoji mode: emoji
|
|
1222
|
+
description: "Pure gitmoji mode (vs --gitmoji): emoji only (emoji: subject)"
|
|
1223
|
+
});
|
|
1224
|
+
model = Option.String("-m,--model", {
|
|
1225
|
+
required: false,
|
|
1226
|
+
description: "Model provider/name (e.g. github-copilot/gpt-5)"
|
|
975
1227
|
});
|
|
976
|
-
reasoning = Option.String("--reasoning", { required: false });
|
|
977
1228
|
async execute() {
|
|
978
1229
|
const config = await loadConfig();
|
|
979
1230
|
if (this.gitmoji || this.gitmojiPure) {
|
|
980
1231
|
config.gitmoji = true;
|
|
981
1232
|
config.gitmojiMode = this.gitmojiPure ? "gitmoji-pure" : "gitmoji";
|
|
982
1233
|
}
|
|
983
|
-
if (this.
|
|
1234
|
+
if (this.model) config.model = this.model;
|
|
984
1235
|
await runGenerate(config);
|
|
985
1236
|
}
|
|
986
1237
|
};
|
|
987
1238
|
var SplitCommand = class extends Command {
|
|
988
1239
|
static paths = [[`split`]];
|
|
1240
|
+
static usage = Command.Usage({
|
|
1241
|
+
description: "Propose multiple smaller conventional commits for current staged diff.",
|
|
1242
|
+
details: `Analyzes staged changes, groups them logically and suggests multiple commit messages.
|
|
1243
|
+
Use --max to limit the number of proposals.`,
|
|
1244
|
+
examples: [
|
|
1245
|
+
[
|
|
1246
|
+
"Split into at most 3 commits with gitmoji",
|
|
1247
|
+
"ai-conventional-commit split --max 3 --gitmoji"
|
|
1248
|
+
]
|
|
1249
|
+
]
|
|
1250
|
+
});
|
|
989
1251
|
max = Option.String("--max", { description: "Max proposed commits", required: false });
|
|
990
|
-
gitmoji = Option.Boolean("--gitmoji", false
|
|
991
|
-
|
|
992
|
-
|
|
1252
|
+
gitmoji = Option.Boolean("--gitmoji", false, {
|
|
1253
|
+
description: "Gitmoji mode (vs --gitmoji-pure): emoji + type"
|
|
1254
|
+
});
|
|
1255
|
+
gitmojiPure = Option.Boolean("--gitmoji-pure", false, {
|
|
1256
|
+
description: "Pure gitmoji mode (vs --gitmoji): emoji only"
|
|
1257
|
+
});
|
|
1258
|
+
model = Option.String("-m,--model", {
|
|
1259
|
+
required: false,
|
|
1260
|
+
description: "Model provider/name override"
|
|
1261
|
+
});
|
|
993
1262
|
async execute() {
|
|
994
1263
|
const config = await loadConfig();
|
|
995
1264
|
if (this.gitmoji || this.gitmojiPure) {
|
|
996
1265
|
config.gitmoji = true;
|
|
997
1266
|
config.gitmojiMode = this.gitmojiPure ? "gitmoji-pure" : "gitmoji";
|
|
998
1267
|
}
|
|
999
|
-
if (this.
|
|
1268
|
+
if (this.model) config.model = this.model;
|
|
1000
1269
|
await runSplit(config, this.max ? parseInt(this.max, 10) : void 0);
|
|
1001
1270
|
}
|
|
1002
1271
|
};
|
|
1003
1272
|
var RefineCommand = class extends Command {
|
|
1004
1273
|
static paths = [[`refine`]];
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1274
|
+
static usage = Command.Usage({
|
|
1275
|
+
description: "Refine the last (or chosen) commit message with style rules.",
|
|
1276
|
+
details: `Allows targeted improvements: shorter/longer length, inject scope, add emoji, or select a specific index when multiple commits were generated earlier.`,
|
|
1277
|
+
examples: [
|
|
1278
|
+
["Shorten the last commit message", "ai-conventional-commit refine --shorter"],
|
|
1279
|
+
["Add a scope to the last commit", "ai-conventional-commit refine --scope ui"]
|
|
1280
|
+
]
|
|
1281
|
+
});
|
|
1282
|
+
shorter = Option.Boolean("--shorter", false, { description: "Make message more concise" });
|
|
1283
|
+
longer = Option.Boolean("--longer", false, { description: "Expand message with detail" });
|
|
1284
|
+
scope = Option.String("--scope", { description: "Override/add scope (e.g. ui, api)" });
|
|
1285
|
+
emoji = Option.Boolean("--emoji", false, { description: "Add appropriate gitmoji (non-pure)" });
|
|
1286
|
+
index = Option.String("--index", {
|
|
1287
|
+
description: "Select commit index if multiple were generated"
|
|
1288
|
+
});
|
|
1289
|
+
model = Option.String("-m,--model", {
|
|
1290
|
+
required: false,
|
|
1291
|
+
description: "Model provider/name override"
|
|
1292
|
+
});
|
|
1011
1293
|
async execute() {
|
|
1012
1294
|
const config = await loadConfig();
|
|
1013
|
-
if (this.
|
|
1295
|
+
if (this.model) config.model = this.model;
|
|
1014
1296
|
await runRefine(config, {
|
|
1015
1297
|
shorter: this.shorter,
|
|
1016
1298
|
longer: this.longer,
|
|
@@ -1020,14 +1302,57 @@ var RefineCommand = class extends Command {
|
|
|
1020
1302
|
});
|
|
1021
1303
|
}
|
|
1022
1304
|
};
|
|
1305
|
+
var HelpCommand = class extends Command {
|
|
1306
|
+
static paths = [[`--help`], [`-h`]];
|
|
1307
|
+
// capture explicit help
|
|
1308
|
+
async execute() {
|
|
1309
|
+
this.context.stdout.write(globalHelp() + "\n");
|
|
1310
|
+
}
|
|
1311
|
+
};
|
|
1312
|
+
function globalHelp() {
|
|
1313
|
+
return `ai-conventional-commit v${pkgVersion}
|
|
1314
|
+
|
|
1315
|
+
Usage:
|
|
1316
|
+
ai-conventional-commit [generate] [options] Generate a commit (default)
|
|
1317
|
+
ai-conventional-commit split [options] Propose multiple commits
|
|
1318
|
+
ai-conventional-commit refine [options] Refine last or indexed commit
|
|
1319
|
+
|
|
1320
|
+
Global Options:
|
|
1321
|
+
-m, --model <provider/name> Override model provider/name
|
|
1322
|
+
--gitmoji[-pure] Gitmoji modes: emoji + type (default) or pure emoji only
|
|
1323
|
+
-h, --help Show this help
|
|
1324
|
+
-V, --version Show version
|
|
1325
|
+
|
|
1326
|
+
Refine Options:
|
|
1327
|
+
--shorter / --longer Adjust message length
|
|
1328
|
+
--scope <scope> Add or replace scope
|
|
1329
|
+
--emoji Add suitable gitmoji
|
|
1330
|
+
--index <n> Select commit index
|
|
1331
|
+
|
|
1332
|
+
Examples:
|
|
1333
|
+
ai-conventional-commit --gitmoji
|
|
1334
|
+
ai-conventional-commit --gitmoji-pure
|
|
1335
|
+
ai-conventional-commit split --max 3 --gitmoji
|
|
1336
|
+
ai-conventional-commit refine --scope api --emoji
|
|
1337
|
+
`;
|
|
1338
|
+
}
|
|
1339
|
+
var VersionCommand = class extends Command {
|
|
1340
|
+
static paths = [[`--version`], [`-V`]];
|
|
1341
|
+
async execute() {
|
|
1342
|
+
this.context.stdout.write(`${pkgVersion}
|
|
1343
|
+
`);
|
|
1344
|
+
}
|
|
1345
|
+
};
|
|
1023
1346
|
var cli = new Cli({
|
|
1024
1347
|
binaryLabel: "ai-conventional-commit",
|
|
1025
1348
|
binaryName: "ai-conventional-commit",
|
|
1026
|
-
binaryVersion:
|
|
1349
|
+
binaryVersion: pkgVersion
|
|
1027
1350
|
});
|
|
1028
1351
|
cli.register(GenerateCommand);
|
|
1029
1352
|
cli.register(SplitCommand);
|
|
1030
1353
|
cli.register(RefineCommand);
|
|
1354
|
+
cli.register(HelpCommand);
|
|
1355
|
+
cli.register(VersionCommand);
|
|
1031
1356
|
cli.runExit(process.argv.slice(2), {
|
|
1032
1357
|
stdin: process.stdin,
|
|
1033
1358
|
stdout: process.stdout,
|