@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.
Files changed (4) hide show
  1. package/README.md +55 -17
  2. package/dist/index.cjs +197 -55
  3. package/dist/index.js +493 -168
  4. 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 chalk from "chalk";
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
- `Reasoning Depth: ${config.reasoning || "low"} (low=minimal concise reasons, medium=balanced detail, high=very detailed). Adjust reasons verbosity accordingly.`
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
- async function animateHeader() {
551
- const text = "ai-conventional-commit";
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 " + chalk.bold(text));
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
- for (const color of palette) {
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 " + frame);
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
- await animateHeader();
587
- console.log("\u2502");
588
- console.log(
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
- setPhase("Result found");
632
- spinner.stopAndPersist({ symbol: "\u25C6", text: " " + chalk.bold("Result found:") });
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
- console.log(" " + chalk.white(chosen.title));
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
- console.log(chalk.red("! Validation issues:"));
659
- errors.forEach((e) => console.log(" -", e));
660
- console.log("\u2502");
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
- console.log("Aborted.");
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
- console.log(chalk.green("Commit created."));
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: " Use this commit 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 chalk2 from "chalk";
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
- console.log("\u25C7 Clustering changes");
749
- clusterHunks(files);
750
- console.log("\u25C7 Profiling style");
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
- console.log("\u25C7 Parsing response");
773
- let plan;
774
- try {
775
- plan = extractJSON(raw);
776
- } catch (e) {
777
- console.log("JSON parse error: " + e.message);
778
- return;
779
- }
780
- console.log("\u25C7 Plan received");
781
- let candidates = await applyTransforms(plan.commits, plugins, {
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
- console.log(chalk2.cyan("\nProposed commits:"));
793
- candidates.forEach((c) => {
794
- console.log(chalk2.yellow(`\u2022 ${c.title}`));
795
- if (c.body) {
796
- const indent = " ";
797
- c.body.split("\n").forEach((line) => {
798
- if (line.trim().length === 0) console.log(indent);
799
- else console.log(indent + chalk2.gray(line));
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: "Apply these commit messages?",
931
+ message: "Use the commits?",
808
932
  choices: [
809
- { name: "\u25CF Yes", value: true },
810
- { name: "\u25CB No", value: false }
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
- console.log("Aborted.");
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
- const pluginErrors = await runValidations(candidate, plugins, {
821
- cwd: process.cwd(),
822
- env: process.env
823
- });
824
- const guardErrors = checkCandidate(candidate);
825
- const errors = [...pluginErrors, ...guardErrors];
826
- if (errors.length) {
827
- console.log(chalk2.red("Skipping commit due to errors:"), candidate.title);
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 chalk3 from "chalk";
844
- import ora2 from "ora";
845
- import { readFileSync, existsSync as existsSync3 } from "fs";
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 spinner = ora2("Loading last session").start();
1017
+ const startedAt = Date.now();
873
1018
  const session = loadSession();
874
1019
  if (!session) {
875
- spinner.fail("No previous session found.");
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 ?? 0;
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 = buildRefineMessages({
900
- originalPlan: plan,
901
- index,
902
- instructions,
903
- config
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
- const raw = await provider.chat(messages, { maxTokens: config.maxTokens });
906
- let refined;
907
- try {
908
- refined = extractJSON(raw);
909
- } catch (e) {
910
- console.error("Failed to parse refine response:", e.message);
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
- refined.commits[0].title = formatCommitTitle(refined.commits[0].title, {
915
- allowGitmoji: !!config.gitmoji || !!options.emoji,
916
- mode: config.gitmojiMode || "standard"
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`], [`run`], [`commit`], []];
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 acts as type (emoji: subject)"
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: subject (no type)"
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.reasoning) config.reasoning = this.reasoning;
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
- gitmojiPure = Option.Boolean("--gitmoji-pure", false);
992
- reasoning = Option.String("--reasoning", { required: false });
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.reasoning) config.reasoning = this.reasoning;
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
- shorter = Option.Boolean("--shorter", false);
1006
- longer = Option.Boolean("--longer", false);
1007
- scope = Option.String("--scope");
1008
- emoji = Option.Boolean("--emoji", false);
1009
- index = Option.String("--index");
1010
- reasoning = Option.String("--reasoning", { required: false });
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.reasoning) config.reasoning = this.reasoning;
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: "0.1.0"
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,