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.
- package/README.md +1 -3
- package/dist/index.js +225 -10
- 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
|
|
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("
|
|
776
|
-
|
|
857
|
+
s.start("Checking staged changes...");
|
|
858
|
+
let diff = await getStagedDiffSmart();
|
|
777
859
|
if (!diff) {
|
|
778
860
|
s.stop("No staged changes found.");
|
|
779
|
-
|
|
780
|
-
|
|
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) {
|