commit-agent-cli 0.2.1 → 0.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +1 -3
  2. package/dist/index.js +225 -10
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -1,7 +1,5 @@
1
1
  # commit-agent-cli
2
2
 
3
- > AI-powered git commit message generator using Claude Sonnet 4.5 / Opus 4.5 or Google Gemini and LangGraph
4
-
5
3
  Generate intelligent, context-aware commit messages by simply typing `commit`.
6
4
 
7
5
  ## Quick Start
@@ -27,7 +25,7 @@ You'll be prompted to:
27
25
 
28
26
  ## Features
29
27
 
30
- Powered by Claude Sonnet 4.5 / Opus 4.5 or Google Gemini, this tool autonomously explores your codebase to generate intelligent commit messages with full transparency into its reasoning process. Supports customizable commit styles with secure local configuration storage and the ability to switch between AI providers at any time.
28
+ Powered by Claude and Gemini, this tool intelligently explores your codebase to generate clear, context-aware commit messages with full transparency. Enjoy customizable commit styles, secure local configuration, and seamless switching between AI providers.
31
29
 
32
30
  ## Documentation
33
31
 
package/dist/index.js CHANGED
@@ -100,7 +100,7 @@ var require_picocolors = __commonJS({
100
100
 
101
101
  // src/index.ts
102
102
  import "dotenv/config";
103
- import { intro, outro, text, spinner, isCancel, cancel, note, select } from "@clack/prompts";
103
+ import { intro, outro, text, spinner, isCancel, cancel, note, select, multiselect } from "@clack/prompts";
104
104
 
105
105
  // src/git.ts
106
106
  import { execa } from "execa";
@@ -154,6 +154,50 @@ async function isGitRepository() {
154
154
  return false;
155
155
  }
156
156
  }
157
+ async function getStagedFiles() {
158
+ const { stdout } = await execa("git", ["diff", "--cached", "--name-only"], {
159
+ reject: false
160
+ });
161
+ return stdout ? stdout.split("\n").filter(Boolean) : [];
162
+ }
163
+ async function getUnstagedFiles() {
164
+ const { stdout } = await execa("git", ["diff", "--name-only"], {
165
+ reject: false
166
+ });
167
+ return stdout ? stdout.split("\n").filter(Boolean) : [];
168
+ }
169
+ async function getUntrackedFiles() {
170
+ const { stdout } = await execa("git", ["ls-files", "--others", "--exclude-standard"], {
171
+ reject: false
172
+ });
173
+ return stdout ? stdout.split("\n").filter(Boolean) : [];
174
+ }
175
+ async function getRecentCommits(count = 5) {
176
+ const { stdout } = await execa("git", ["log", `-${count}`, "--pretty=format:%h|%s"], {
177
+ reject: false
178
+ });
179
+ if (!stdout) return [];
180
+ return stdout.split("\n").map((line) => {
181
+ const [hash, ...messageParts] = line.split("|");
182
+ return { hash, message: messageParts.join("|") };
183
+ });
184
+ }
185
+ async function getStagedStats() {
186
+ const { stdout: stats } = await execa("git", ["diff", "--cached", "--stat"], {
187
+ reject: false
188
+ });
189
+ if (!stats) return { files: 0, insertions: 0, deletions: 0 };
190
+ const match = stats.match(/(\d+) files? changed(?:, (\d+) insertions?\(\+\))?(?:, (\d+) deletions?\(-\))?/);
191
+ return {
192
+ files: match ? parseInt(match[1]) : 0,
193
+ insertions: match && match[2] ? parseInt(match[2]) : 0,
194
+ deletions: match && match[3] ? parseInt(match[3]) : 0
195
+ };
196
+ }
197
+ async function stageFiles(files) {
198
+ if (files.length === 0) return;
199
+ await execa("git", ["add", ...files]);
200
+ }
157
201
 
158
202
  // src/agent.ts
159
203
  import { ChatAnthropic } from "@langchain/anthropic";
@@ -445,6 +489,7 @@ import updateNotifier from "update-notifier";
445
489
  import { readFileSync } from "fs";
446
490
  import { fileURLToPath } from "url";
447
491
  import { dirname, join as joinPath } from "path";
492
+ import { execa as execa3 } from "execa";
448
493
  import { homedir } from "os";
449
494
  import { join as join2 } from "path";
450
495
  import { readFile as readFile2, writeFile } from "fs/promises";
@@ -578,6 +623,43 @@ async function promptForApiKey(provider) {
578
623
  return key;
579
624
  }
580
625
  }
626
+ async function showGuidedStaging() {
627
+ const s = spinner();
628
+ s.start("Checking for unstaged and untracked files...");
629
+ const [unstaged, untracked] = await Promise.all([
630
+ getUnstagedFiles(),
631
+ getUntrackedFiles()
632
+ ]);
633
+ s.stop("Files found.");
634
+ const allFiles = [
635
+ ...unstaged.map((f) => ({ file: f, status: "modified" })),
636
+ ...untracked.map((f) => ({ file: f, status: "untracked" }))
637
+ ];
638
+ if (allFiles.length === 0) {
639
+ note("No changes to stage. Make some changes first!", "No Changes");
640
+ return false;
641
+ }
642
+ note(`Found ${allFiles.length} file(s) with changes`, "Available Files");
643
+ const selectedFiles = await multiselect({
644
+ message: "Select files to stage (Space to select, Enter to continue):",
645
+ options: allFiles.map(({ file, status }) => ({
646
+ value: file,
647
+ label: `${file} (${status})`
648
+ })),
649
+ required: false
650
+ });
651
+ if (isCancel(selectedFiles)) {
652
+ return false;
653
+ }
654
+ if (!selectedFiles || selectedFiles.length === 0) {
655
+ note('No files selected. You can run "git add <files>" manually.', "Skipped");
656
+ return false;
657
+ }
658
+ s.start("Staging selected files...");
659
+ await stageFiles(selectedFiles);
660
+ s.stop(`Staged ${selectedFiles.length} file(s).`);
661
+ return true;
662
+ }
581
663
  async function showSettingsMenu(currentConfig2) {
582
664
  const providerLabel = currentConfig2.provider === "anthropic" ? "Anthropic (Claude)" : "Google (Gemini)";
583
665
  const modelName = currentConfig2.provider === "anthropic" ? ANTHROPIC_MODELS[currentConfig2.model] || currentConfig2.model : GOOGLE_MODELS[currentConfig2.model] || currentConfig2.model;
@@ -772,14 +854,116 @@ async function main() {
772
854
  note(`Preferences saved! You can change these anytime in the settings menu.`, "Setup Complete");
773
855
  }
774
856
  const s = spinner();
775
- s.start("Analyzing staged changes...");
776
- const diff = await getStagedDiffSmart();
857
+ s.start("Checking staged changes...");
858
+ let diff = await getStagedDiffSmart();
777
859
  if (!diff) {
778
860
  s.stop("No staged changes found.");
779
- cancel('Please stage your changes using "git add" first.');
780
- process.exit(0);
861
+ const shouldStage = await select({
862
+ message: "No staged changes. Would you like to stage files now?",
863
+ options: [
864
+ { value: true, label: "Yes, show me files to stage" },
865
+ { value: false, label: "No, I'll stage manually" }
866
+ ],
867
+ initialValue: true
868
+ });
869
+ if (isCancel(shouldStage) || !shouldStage) {
870
+ cancel('Please stage your changes using "git add" first.');
871
+ process.exit(0);
872
+ }
873
+ const staged = await showGuidedStaging();
874
+ if (!staged) {
875
+ cancel("No files were staged. Exiting.");
876
+ process.exit(0);
877
+ }
878
+ s.start("Analyzing staged changes...");
879
+ diff = await getStagedDiffSmart();
880
+ if (!diff) {
881
+ s.stop("Still no staged changes.");
882
+ cancel("Something went wrong. Please try again.");
883
+ process.exit(0);
884
+ }
781
885
  }
782
886
  s.stop("Changes detected.");
887
+ const stats = await getStagedStats();
888
+ const stagedFiles = await getStagedFiles();
889
+ let previewMessage = import_picocolors.default.cyan(`${stats.files} file(s) changed`);
890
+ if (stats.insertions > 0) previewMessage += import_picocolors.default.green(`, ${stats.insertions} insertion(s)`);
891
+ if (stats.deletions > 0) previewMessage += import_picocolors.default.red(`, ${stats.deletions} deletion(s)`);
892
+ previewMessage += "\n" + stagedFiles.map((f) => ` \u2022 ${f}`).join("\n");
893
+ note(previewMessage, "Changes Summary");
894
+ const recentCommits = await getRecentCommits(5);
895
+ if (recentCommits.length > 0) {
896
+ const commitsMessage = recentCommits.map((c) => ` ${import_picocolors.default.dim(c.hash)} ${c.message}`).join("\n");
897
+ note(commitsMessage, "Recent Commits");
898
+ }
899
+ const [unstaged, untracked] = await Promise.all([
900
+ getUnstagedFiles(),
901
+ getUntrackedFiles()
902
+ ]);
903
+ if (unstaged.length > 0 || untracked.length > 0) {
904
+ const unstagedCount = unstaged.length + untracked.length;
905
+ const warningMessage = `You have ${unstagedCount} unstaged file(s):
906
+ ` + [...unstaged.map((f) => ` \u2022 ${f} (modified)`), ...untracked.map((f) => ` \u2022 ${f} (untracked)`)].join("\n");
907
+ note(import_picocolors.default.yellow(warningMessage), import_picocolors.default.yellow("\u26A0 Unstaged Changes"));
908
+ const includeUnstaged = await select({
909
+ message: "Include these files in this commit?",
910
+ options: [
911
+ { value: false, label: "No, continue with current staging" },
912
+ { value: true, label: "Yes, let me select which ones" }
913
+ ],
914
+ initialValue: false
915
+ });
916
+ if (!isCancel(includeUnstaged) && includeUnstaged) {
917
+ const additionalFiles = await multiselect({
918
+ message: "Select additional files to stage:",
919
+ options: [
920
+ ...unstaged.map((f) => ({ value: f, label: `${f} (modified)` })),
921
+ ...untracked.map((f) => ({ value: f, label: `${f} (untracked)` }))
922
+ ],
923
+ required: false
924
+ });
925
+ if (!isCancel(additionalFiles) && additionalFiles && additionalFiles.length > 0) {
926
+ s.start("Staging additional files...");
927
+ await stageFiles(additionalFiles);
928
+ diff = await getStagedDiffSmart();
929
+ s.stop("Additional files staged.");
930
+ }
931
+ }
932
+ }
933
+ const totalChanges = stats.insertions + stats.deletions;
934
+ if (stats.files >= 10 || totalChanges >= 500) {
935
+ const largeChangesetMessage = import_picocolors.default.yellow(
936
+ `\u26A0 Large changeset detected (${stats.files} files, ${totalChanges} changes)
937
+ Consider splitting into smaller, focused commits for better review.`
938
+ );
939
+ note(largeChangesetMessage, import_picocolors.default.yellow("Large Changeset Warning"));
940
+ const proceedWithLarge = await select({
941
+ message: "How would you like to proceed?",
942
+ options: [
943
+ { value: "continue", label: "Continue with all changes" },
944
+ { value: "reselect", label: "Let me re-select files to commit" },
945
+ { value: "cancel", label: "Cancel and stage manually" }
946
+ ],
947
+ initialValue: "continue"
948
+ });
949
+ if (isCancel(proceedWithLarge) || proceedWithLarge === "cancel") {
950
+ cancel('Cancelled. Use "git add" to stage specific files.');
951
+ process.exit(0);
952
+ }
953
+ if (proceedWithLarge === "reselect") {
954
+ await execa3("git", ["reset", "HEAD"]);
955
+ const restaged = await showGuidedStaging();
956
+ if (!restaged) {
957
+ cancel("No files were staged. Exiting.");
958
+ process.exit(0);
959
+ }
960
+ diff = await getStagedDiffSmart();
961
+ if (!diff) {
962
+ cancel("No staged changes. Exiting.");
963
+ process.exit(0);
964
+ }
965
+ }
966
+ }
783
967
  let commitMessage = "";
784
968
  let confirmed = false;
785
969
  let userFeedback = void 0;
@@ -854,13 +1038,16 @@ async function main() {
854
1038
  }
855
1039
  const formattedMessage = wrappedLines.map((line) => import_picocolors.default.cyan(line)).join("\n");
856
1040
  note(formattedMessage, import_picocolors.default.bold("Proposed Commit Message"));
1041
+ const isMultiLine = commitMessage.includes("\n");
1042
+ const actionOptions = [
1043
+ { value: "commit", label: "Yes, commit" },
1044
+ ...isMultiLine ? [] : [{ value: "edit", label: "Edit message" }],
1045
+ { value: "regenerate", label: "Regenerate" },
1046
+ { value: "settings", label: "Change settings" }
1047
+ ];
857
1048
  const action = await select({
858
1049
  message: "Do you want to use this message?",
859
- options: [
860
- { value: "commit", label: "Yes, commit" },
861
- { value: "regenerate", label: "No, regenerate" },
862
- { value: "settings", label: "Change settings" }
863
- ]
1050
+ options: actionOptions
864
1051
  });
865
1052
  if (isCancel(action)) {
866
1053
  cancel("Operation cancelled.");
@@ -868,6 +1055,34 @@ async function main() {
868
1055
  }
869
1056
  if (action === "commit") {
870
1057
  confirmed = true;
1058
+ } else if (action === "edit") {
1059
+ const editedMessage = await text({
1060
+ message: "Edit your commit message:",
1061
+ initialValue: commitMessage,
1062
+ validate: (value) => {
1063
+ if (!value || value.trim() === "") return "Commit message cannot be empty";
1064
+ }
1065
+ });
1066
+ if (isCancel(editedMessage)) {
1067
+ continue;
1068
+ }
1069
+ commitMessage = editedMessage;
1070
+ const editedFormattedMessage = editedMessage.split("\n").map((line) => import_picocolors.default.cyan(line)).join("\n");
1071
+ note(editedFormattedMessage, import_picocolors.default.bold("Edited Commit Message"));
1072
+ const confirmEdited = await select({
1073
+ message: "Use this edited message?",
1074
+ options: [
1075
+ { value: true, label: "Yes, commit" },
1076
+ { value: false, label: "No, go back" }
1077
+ ],
1078
+ initialValue: true
1079
+ });
1080
+ if (isCancel(confirmEdited)) {
1081
+ continue;
1082
+ }
1083
+ if (confirmEdited) {
1084
+ confirmed = true;
1085
+ }
871
1086
  } else if (action === "settings") {
872
1087
  const settingsResult = await showSettingsMenu(config);
873
1088
  if (settingsResult) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "commit-agent-cli",
3
- "version": "0.2.1",
3
+ "version": "0.2.2",
4
4
  "description": "AI-powered git commit CLI using LangGraph and Claude 4.5 or Gemini",
5
5
  "main": "dist/index.js",
6
6
  "types": "./dist/index.d.ts",